Add read-only support and UI indicators for MikroTik dynamic peers (#733)
Docker / Build and Push (push) Canceled after 0s
github-pages / deploy (push) Canceled after 0s
Test / make test (push) Canceled after 0s
Docker / release (push) Canceled after 0s

* 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:
Ondřej Zmatlík
2026-08-27 09:47:23 +02:00
committed by GitHub
parent 4a9bcd1b82
commit 87aef1c8ff
25 changed files with 1121 additions and 990 deletions
@@ -72,6 +72,14 @@ const isBackendValid = computed(() => {
return valid return valid
}) })
const isHookSupported = computed(() => {
if (!formData.value.Backend) return false;
let availableBackends = settings.Setting('AvailableBackends') || [];
let backend = availableBackends.find(b => b.Id === formData.value.Backend);
if (!backend) return false;
return backend.Type === 'local' || backend.Type === 'mikrotik';
})
// functions // functions
watch(() => props.visible, async (newValue, oldValue) => { watch(() => props.visible, async (newValue, oldValue) => {
@@ -499,7 +507,7 @@ async function del() {
</div> </div>
</div> </div>
</fieldset> </fieldset>
<fieldset v-if="formData.Backend==='local'"> <fieldset v-if="isHookSupported">
<legend class="mt-4">{{ $t('modals.interface-edit.header-hooks') }}</legend> <legend class="mt-4">{{ $t('modals.interface-edit.header-hooks') }}</legend>
<div class="form-group"> <div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.pre-up.label') }}</label> <label class="form-label mt-4">{{ $t('modals.interface-edit.pre-up.label') }}</label>
+3 -1
View File
@@ -138,7 +138,9 @@
"peer-expiring": "Peer läuft ab am", "peer-expiring": "Peer läuft ab am",
"peer-connected": "Verbunden", "peer-connected": "Verbunden",
"peer-not-connected": "Nicht verbunden", "peer-not-connected": "Nicht verbunden",
"peer-handshake": "Letzter Handshake:" "peer-handshake": "Letzter Handshake:",
"peer-dynamic": "Dynamischer Peer",
"peer-dynamic-no-edit": "Dieser Peer wurde dynamisch erstellt und kann nicht bearbeitet oder gelöscht werden."
}, },
"users": { "users": {
"headline": "Benutzerverwaltung", "headline": "Benutzerverwaltung",
+3 -1
View File
@@ -138,7 +138,9 @@
"peer-expiring": "Peer is expiring at", "peer-expiring": "Peer is expiring at",
"peer-connected": "Connected", "peer-connected": "Connected",
"peer-not-connected": "Not Connected", "peer-not-connected": "Not Connected",
"peer-handshake": "Last handshake:" "peer-handshake": "Last handshake:",
"peer-dynamic": "Dynamic Peer",
"peer-dynamic-no-edit": "This peer is dynamically created and cannot be edited or deleted."
}, },
"users": { "users": {
"headline": "User Administration", "headline": "User Administration",
+3 -1
View File
@@ -153,7 +153,9 @@
"peer-expiring": "El peer expira en", "peer-expiring": "El peer expira en",
"peer-connected": "Conectado", "peer-connected": "Conectado",
"peer-not-connected": "No conectado", "peer-not-connected": "No conectado",
"peer-handshake": "Último handshake:" "peer-handshake": "Último handshake:",
"peer-dynamic": "Peer dinámico",
"peer-dynamic-no-edit": "Este peer se ha creado de forma dinámica y no se puede editar ni eliminar."
}, },
"users": { "users": {
"headline": "Administración de usuarios", "headline": "Administración de usuarios",
+3 -1
View File
@@ -126,7 +126,9 @@
"peer-expiring": "Le pair expire le", "peer-expiring": "Le pair expire le",
"peer-connected": "Connecté", "peer-connected": "Connecté",
"peer-not-connected": "Non connecté", "peer-not-connected": "Non connecté",
"peer-handshake": "Dernière négociation :" "peer-handshake": "Dernière négociation :",
"peer-dynamic": "Peer dynamique",
"peer-dynamic-no-edit": "Ce peer est créé dynamiquement et ne peut être ni modifié ni supprimé."
}, },
"users": { "users": {
"headline": "Administration des utilisateurs", "headline": "Administration des utilisateurs",
+3 -1
View File
@@ -138,7 +138,9 @@
"peer-expiring": "ピアの有効期限:", "peer-expiring": "ピアの有効期限:",
"peer-connected": "接続中", "peer-connected": "接続中",
"peer-not-connected": "未接続", "peer-not-connected": "未接続",
"peer-handshake": "最終ハンドシェイク:" "peer-handshake": "最終ハンドシェイク:",
"peer-dynamic": "動的ピア",
"peer-dynamic-no-edit": "このピアは動的に作成されており、編集または削除することはできません。"
}, },
"users": { "users": {
"headline": "ユーザー管理", "headline": "ユーザー管理",
+4 -3
View File
@@ -127,7 +127,9 @@
"peer-expiring": "피어 만료 예정:", "peer-expiring": "피어 만료 예정:",
"peer-connected": "연결됨", "peer-connected": "연결됨",
"peer-not-connected": "연결되지 않음", "peer-not-connected": "연결되지 않음",
"peer-handshake": "마지막 핸드셰이크:" "peer-handshake": "마지막 핸드셰이크:",
"peer-dynamic": "동적 피어",
"peer-dynamic-no-edit": "이 피어는 동적으로 생성되었으며 편집하거나 삭제할 수 없습니다."
}, },
"users": { "users": {
"headline": "사용자 관리", "headline": "사용자 관리",
@@ -532,5 +534,4 @@
} }
} }
} }
} }
+3 -1
View File
@@ -128,7 +128,9 @@
"peer-expiring": "Peer expira em", "peer-expiring": "Peer expira em",
"peer-connected": "Conectado", "peer-connected": "Conectado",
"peer-not-connected": "Não Conectado", "peer-not-connected": "Não Conectado",
"peer-handshake": "Último handshake:" "peer-handshake": "Último handshake:",
"peer-dynamic": "Peer Dinâmico",
"peer-dynamic-no-edit": "Este peer é criado dinamicamente e não pode ser editado ou excluído."
}, },
"users": { "users": {
"headline": "Administração de Utilizadores", "headline": "Administração de Utilizadores",
+3 -1
View File
@@ -134,7 +134,9 @@
"peer-expiring": "Пир истекает в", "peer-expiring": "Пир истекает в",
"peer-connected": "Подключено", "peer-connected": "Подключено",
"peer-not-connected": "Не подключено", "peer-not-connected": "Не подключено",
"peer-handshake": "Последнее рукопожатие:" "peer-handshake": "Последнее рукопожатие:",
"peer-dynamic": "Динамический пир",
"peer-dynamic-no-edit": "Этот пир создан динамически и не может быть изменен или удален."
}, },
"users": { "users": {
"headline": "Администрирование пользователей", "headline": "Администрирование пользователей",
+3 -1
View File
@@ -126,7 +126,9 @@
"peer-expiring": "Пір припиняє дію о", "peer-expiring": "Пір припиняє дію о",
"peer-connected": "Підключено", "peer-connected": "Підключено",
"peer-not-connected": "Не підключено", "peer-not-connected": "Не підключено",
"peer-handshake": "Останнє рукостискання:" "peer-handshake": "Останнє рукостискання:",
"peer-dynamic": "Динамічний пір",
"peer-dynamic-no-edit": "Цей пір створено динамічно і його не можна редагувати або видаляти."
}, },
"users": { "users": {
"headline": "Адміністрування користувачів", "headline": "Адміністрування користувачів",
+3 -1
View File
@@ -125,7 +125,9 @@
"peer-expiring": "Peer sẽ hết hạn vào", "peer-expiring": "Peer sẽ hết hạn vào",
"peer-connected": "Đã kết nối", "peer-connected": "Đã kết nối",
"peer-not-connected": "Chưa kết nối", "peer-not-connected": "Chưa kết nối",
"peer-handshake": "Lần bắt tay cuối cùng:" "peer-handshake": "Lần bắt tay cuối cùng:",
"peer-dynamic": "Peer động",
"peer-dynamic-no-edit": "Peer này được tạo động và không thể chỉnh sửa hoặc xóa."
}, },
"users": { "users": {
"headline": "Quản trị Người dùng", "headline": "Quản trị Người dùng",
+4 -3
View File
@@ -125,7 +125,9 @@
"peer-expiring": "节点将在以下时间过期: ", "peer-expiring": "节点将在以下时间过期: ",
"peer-connected": "已连接", "peer-connected": "已连接",
"peer-not-connected": "未连接", "peer-not-connected": "未连接",
"peer-handshake": "最后一次握手: " "peer-handshake": "最后一次握手: ",
"peer-dynamic": "动态对等节点",
"peer-dynamic-no-edit": "此对等节点是动态创建的,无法编辑或删除。"
}, },
"users": { "users": {
"headline": "用户管理", "headline": "用户管理",
@@ -492,5 +494,4 @@
} }
} }
} }
} }
+15 -3
View File
@@ -151,7 +151,9 @@ async function bulkDisable() {
function toggleSelectAll() { function toggleSelectAll() {
peers.FilteredAndPaged.forEach(peer => { peers.FilteredAndPaged.forEach(peer => {
if (!peer.IsDynamic) {
peer.IsSelected = selectAll.value; peer.IsSelected = selectAll.value;
}
}); });
} }
@@ -444,13 +446,17 @@ onMounted(async () => {
<tbody> <tbody>
<tr v-for="peer in peers.FilteredAndPaged" :key="peer.Identifier"> <tr v-for="peer in peers.FilteredAndPaged" :key="peer.Identifier">
<th scope="row"> <th scope="row">
<input class="form-check-input" type="checkbox" v-model="peer.IsSelected"> <input class="form-check-input" type="checkbox" v-model="peer.IsSelected" :disabled="peer.IsDynamic" :title="peer.IsDynamic ? $t('interfaces.peer-dynamic-no-edit') : ''">
</th> </th>
<td class="text-center"> <td class="text-center">
<span v-if="peer.Disabled" class="text-danger" :title="$t('interfaces.peer-disabled') + ' ' + peer.DisabledReason"><i class="fa fa-circle-xmark"></i></span> <span v-if="peer.Disabled" class="text-danger" :title="$t('interfaces.peer-disabled') + ' ' + peer.DisabledReason"><i class="fa fa-circle-xmark"></i></span>
<span v-if="!peer.Disabled && peer.ExpiresAt" class="text-warning" :title="$t('interfaces.peer-expiring') + ' ' + peer.ExpiresAt"><i class="fas fa-hourglass-end expiring-peer"></i></span> <span v-if="!peer.Disabled && peer.ExpiresAt" class="text-warning" :title="$t('interfaces.peer-expiring') + ' ' + peer.ExpiresAt"><i class="fas fa-hourglass-end expiring-peer"></i></span>
</td> </td>
<td><span v-if="peer.DisplayName" :title="peer.Identifier">{{peer.DisplayName}}</span><span v-else :title="peer.Identifier">{{ $filters.truncate(peer.Identifier, 10)}}</span></td> <td>
<span v-if="peer.DisplayName" :title="peer.Identifier">{{peer.DisplayName}}</span>
<span v-else :title="peer.Identifier">{{ $filters.truncate(peer.Identifier, 10)}}</span>
<span v-if="peer.IsDynamic" class="badge bg-info-subtle text-info-emphasis ms-1 border border-info-subtle" :title="$t('interfaces.peer-dynamic')"><i class="fa-solid fa-robot me-1"></i>{{ $t('interfaces.peer-dynamic') }}</span>
</td>
<td><span :title="peer.UserDisplayName">{{peer.UserIdentifier}}</span></td> <td><span :title="peer.UserDisplayName">{{peer.UserIdentifier}}</span></td>
<td> <td>
<span v-for="ip in peer.Addresses" :key="ip" class="badge bg-light me-1">{{ ip }}</span> <span v-for="ip in peer.Addresses" :key="ip" class="badge bg-light me-1">{{ ip }}</span>
@@ -474,7 +480,13 @@ onMounted(async () => {
</td> </td>
<td class="text-center"> <td class="text-center">
<a href="#" :title="$t('interfaces.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a> <a href="#" :title="$t('interfaces.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a>
<a href="#" :title="$t('interfaces.button-edit-peer')" @click.prevent="editPeerId=peer.Identifier"><i class="fas fa-cog"></i></a>
<span v-if="peer.IsDynamic" :title="$t('interfaces.peer-dynamic-no-edit')" class="text-muted" style="cursor: not-allowed;">
<i class="fas fa-cog"></i>
</span>
<a v-else href="#" :title="$t('interfaces.button-edit-peer')" @click.prevent="editPeerId=peer.Identifier">
<i class="fas fa-cog"></i>
</a>
</td> </td>
</tr> </tr>
</tbody> </tbody>
+62 -9
View File
@@ -296,7 +296,7 @@ func (c *MikrotikController) GetPeers(ctx context.Context, deviceId domain.Inter
PropList: []string{ PropList: []string{
".id", "name", "allowed-address", "client-address", "client-endpoint", "client-keepalive", "comment", ".id", "name", "allowed-address", "client-address", "client-endpoint", "client-keepalive", "comment",
"current-endpoint-address", "current-endpoint-port", "last-handshake", "persistent-keepalive", "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{ Filters: map[string]string{
"interface": string(deviceId), "interface": string(deviceId),
@@ -376,6 +376,7 @@ func (c *MikrotikController) convertWireGuardPeer(peer lowlevel.GenericJsonObjec
Comment: peer.GetString("comment"), Comment: peer.GetString("comment"),
IsResponder: peer.GetBool("responder"), IsResponder: peer.GetBool("responder"),
Disabled: peer.GetBool("disabled"), Disabled: peer.GetBool("disabled"),
Dynamic: peer.GetBool("dynamic"),
ClientEndpoint: peer.GetString("client-endpoint"), ClientEndpoint: peer.GetString("client-endpoint"),
ClientAddress: peer.GetString("client-address"), ClientAddress: peer.GetString("client-address"),
ClientDns: peer.GetString("client-dns"), ClientDns: peer.GetString("client-dns"),
@@ -614,15 +615,24 @@ func (c *MikrotikController) SavePeer(
return err return err
} }
peerId := physicalPeer.GetExtras().(domain.MikrotikPeerExtras).Id oldExtras := physicalPeer.GetExtras().(domain.MikrotikPeerExtras)
peerId := oldExtras.Id
dynamic := oldExtras.Dynamic
physicalPeer, err = updateFunc(physicalPeer) physicalPeer, err = updateFunc(physicalPeer)
if err != nil { if err != nil {
return err return err
} }
newExtras := physicalPeer.GetExtras().(domain.MikrotikPeerExtras) 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) 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 { if err := c.updatePeer(ctx, deviceId, physicalPeer); err != nil {
return err return err
} }
@@ -638,7 +648,7 @@ func (c *MikrotikController) getOrCreatePeer(
wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{ wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{
PropList: []string{ PropList: []string{
".id", "name", "public-key", "private-key", "preshared-key", "persistent-keepalive", "client-address", ".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{ Filters: map[string]string{
"public-key": string(id), "public-key": string(id),
@@ -742,7 +752,7 @@ func (c *MikrotikController) DeletePeer(
defer mutex.Unlock() defer mutex.Unlock()
wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{ wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{
PropList: []string{".id"}, PropList: []string{".id", "dynamic"},
Filters: map[string]string{ Filters: map[string]string{
"public-key": string(id), "public-key": string(id),
"interface": string(deviceId), "interface": string(deviceId),
@@ -754,6 +764,10 @@ func (c *MikrotikController) DeletePeer(
if len(wgReply.Data) == 0 { if len(wgReply.Data) == 0 {
return nil // peer does not exist, nothing to delete 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") peerId := wgReply.Data[0].GetString(".id")
deleteReply := c.client.Delete(ctx, "/interface/wireguard/peers/"+peerId) deleteReply := c.client.Delete(ctx, "/interface/wireguard/peers/"+peerId)
@@ -769,12 +783,51 @@ func (c *MikrotikController) DeletePeer(
// region wg-quick-related // region wg-quick-related
func (c *MikrotikController) ExecuteInterfaceHook( func (c *MikrotikController) ExecuteInterfaceHook(
_ context.Context, ctx context.Context,
_ domain.InterfaceIdentifier, id domain.InterfaceIdentifier,
_ string, hookCmd string,
) error { ) error {
// TODO implement me if hookCmd == "" {
slog.Error("interface hooks are not yet supported for Mikrotik backends, please open an issue on GitHub") 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 return nil
} }
@@ -119,6 +119,7 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
names = append(names, model.SettingsBackendNames{ names = append(names, model.SettingsBackendNames{
Id: controller.Id, Id: controller.Id,
Name: displayName, Name: displayName,
Type: controller.Type,
}) })
} }
+1
View File
@@ -20,4 +20,5 @@ type Settings struct {
type SettingsBackendNames struct { type SettingsBackendNames struct {
Id string `json:"Id"` Id string `json:"Id"`
Name string `json:"Name"` 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 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) 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 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 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 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, PublicKey: src.PublicKey,
Disabled: src.IsDisabled(), Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason, DisabledReason: src.DisabledReason,
IsDynamic: src.IsDynamic,
SaveConfig: src.SaveConfig, SaveConfig: src.SaveConfig,
CreateDefaultPeer: src.CreateDefaultPeer, CreateDefaultPeer: src.CreateDefaultPeer,
ListenPort: src.ListenPort, ListenPort: src.ListenPort,
@@ -153,6 +155,7 @@ func NewDomainInterface(src *Interface) *domain.Interface {
PreDown: src.PreDown, PreDown: src.PreDown,
PostDown: src.PostDown, PostDown: src.PostDown,
SaveConfig: src.SaveConfig, SaveConfig: src.SaveConfig,
IsDynamic: src.IsDynamic,
CreateDefaultPeer: src.CreateDefaultPeer, CreateDefaultPeer: src.CreateDefaultPeer,
DisplayName: src.DisplayName, DisplayName: src.DisplayName,
Type: domain.InterfaceType(src.Mode), Type: domain.InterfaceType(src.Mode),
+3
View File
@@ -49,6 +49,7 @@ type Peer struct {
DisabledReason string `json:"DisabledReason"` // the reason why the peer has been disabled DisabledReason string `json:"DisabledReason"` // the reason why the peer has been disabled
ExpiresAt ExpiryDate `json:"ExpiresAt,omitempty"` // expiry dates for peers ExpiresAt ExpiryDate `json:"ExpiresAt,omitempty"` // expiry dates for peers
Notes string `json:"Notes"` // a note field 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 Endpoint ConfigOption[string] `json:"Endpoint"` // the endpoint address
EndpointPublicKey ConfigOption[string] `json:"EndpointPublicKey"` // the endpoint public key EndpointPublicKey ConfigOption[string] `json:"EndpointPublicKey"` // the endpoint public key
@@ -90,6 +91,7 @@ func NewPeer(src *domain.Peer) *Peer {
DisabledReason: src.DisabledReason, DisabledReason: src.DisabledReason,
ExpiresAt: ExpiryDate{src.ExpiresAt}, ExpiresAt: ExpiryDate{src.ExpiresAt},
Notes: src.Notes, Notes: src.Notes,
IsDynamic: src.IsDynamic,
Endpoint: ConfigOptionFromDomain(src.Endpoint), Endpoint: ConfigOptionFromDomain(src.Endpoint),
EndpointPublicKey: ConfigOptionFromDomain(src.EndpointPublicKey), EndpointPublicKey: ConfigOptionFromDomain(src.EndpointPublicKey),
AllowedIPs: StringSliceConfigOptionFromDomain(src.AllowedIPsStr), AllowedIPs: StringSliceConfigOptionFromDomain(src.AllowedIPsStr),
@@ -148,6 +150,7 @@ func NewDomainPeer(src *Peer) *domain.Peer {
InterfaceIdentifier: domain.InterfaceIdentifier(src.InterfaceIdentifier), InterfaceIdentifier: domain.InterfaceIdentifier(src.InterfaceIdentifier),
Disabled: nil, // set below Disabled: nil, // set below
DisabledReason: src.DisabledReason, DisabledReason: src.DisabledReason,
IsDynamic: src.IsDynamic,
ExpiresAt: src.ExpiresAt.Time, ExpiresAt: src.ExpiresAt.Time,
Notes: src.Notes, Notes: src.Notes,
Interface: domain.PeerInterfaceConfig{ Interface: domain.PeerInterfaceConfig{
@@ -63,6 +63,7 @@ func (c *ControllerManager) registerLocalController() error {
Config: config.BackendBase{ Config: config.BackendBase{
Id: config.LocalBackendName, Id: config.LocalBackendName,
DisplayName: "Local WireGuard Controller", DisplayName: "Local WireGuard Controller",
Type: "local",
IgnoredInterfaces: c.cfg.Backend.IgnoredLocalInterfaces, IgnoredInterfaces: c.cfg.Backend.IgnoredLocalInterfaces,
}, },
Implementation: localController, 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) 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{ c.controllers[domain.InterfaceBackend(backendConfig.Id)] = backendInstance{
Config: backendConfig.BackendBase, Config: backendConfig.BackendBase,
Implementation: controller, 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) 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{ c.controllers[domain.InterfaceBackend(backendConfig.Id)] = backendInstance{
Config: backendConfig.BackendBase, Config: backendConfig.BackendBase,
Implementation: controller, Implementation: controller,
+12 -2
View File
@@ -307,9 +307,19 @@ func (m Manager) RestoreInterfaceState(
physicalPeers, _ := controller.GetPeers(ctx, iface.Identifier) physicalPeers, _ := controller.GetPeers(ctx, iface.Identifier)
for _, physicalPeer := range physicalPeers { for _, physicalPeer := range physicalPeers {
isWgPortalPeer := false isWgPortalPeer := false
for _, peer := range peers { for i := range peers {
if peer.Identifier == domain.PeerIdentifier(physicalPeer.PublicKey) { if peers[i].Identifier == domain.PeerIdentifier(physicalPeer.PublicKey) {
isWgPortalPeer = true 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 break
} }
} }
+10 -2
View File
@@ -620,7 +620,11 @@ func (m Manager) getFreshPeerIpConfig(ctx context.Context, iface *domain.Interfa
return 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) currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin && !m.cfg.Core.SelfProvisioningAllowed { if !currentUser.IsAdmin && !m.cfg.Core.SelfProvisioningAllowed {
@@ -649,7 +653,11 @@ func (m Manager) validatePeerCreation(ctx context.Context, _, new *domain.Peer)
return nil 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) currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin && !m.cfg.Core.SelfProvisioningAllowed { if !currentUser.IsAdmin && !m.cfg.Core.SelfProvisioningAllowed {
+1
View File
@@ -61,6 +61,7 @@ func (b *Backend) Validate() error {
type BackendBase struct { type BackendBase struct {
Id string `yaml:"id"` // A unique id for the backend Id string `yaml:"id"` // A unique id for the backend
DisplayName string `yaml:"display_name"` // A display name 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") IgnoredInterfaces []string `yaml:"ignored_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0")
} }
+1
View File
@@ -22,6 +22,7 @@ type MikrotikPeerExtras struct {
Comment string Comment string
IsResponder bool IsResponder bool
Disabled bool Disabled bool
Dynamic bool
ClientEndpoint string ClientEndpoint string
ClientAddress string ClientAddress string
ClientDns string ClientDns string
+2
View File
@@ -58,6 +58,8 @@ type Interface struct {
CreateDefaultPeer bool // if true, default peers will be created for this interface 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, ...) Backend InterfaceBackend // the backend that is used to manage the interface (wgctrl, mikrotik, ...)
DriverType string // the interface driver type (linux, software, ...) 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) 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 DisabledReason string // the reason why the interface has been disabled
+5
View File
@@ -56,6 +56,8 @@ type Peer struct {
// Interface settings for the peer, used to generate the [interface] section in the peer config file // Interface settings for the peer, used to generate the [interface] section in the peer config file
Interface PeerInterfaceConfig `gorm:"embedded"` 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 { func (p *Peer) IsDisabled() bool {
@@ -264,6 +266,7 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
Interface: PeerInterfaceConfig{ Interface: PeerInterfaceConfig{
KeyPair: pp.KeyPair, KeyPair: pp.KeyPair,
}, },
IsDynamic: false,
} }
if pp.GetExtras() == nil { if pp.GetExtras() == nil {
@@ -277,6 +280,7 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
extras := pp.GetExtras().(MikrotikPeerExtras) extras := pp.GetExtras().(MikrotikPeerExtras)
peer.Notes = extras.Comment peer.Notes = extras.Comment
peer.DisplayName = extras.Name 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 if extras.ClientEndpoint != "" { // if the client endpoint is set, we assume that this is a client peer
peer.Endpoint = NewConfigOption(extras.ClientEndpoint, true) peer.Endpoint = NewConfigOption(extras.ClientEndpoint, true)
peer.Interface.Type = InterfaceTypeClient peer.Interface.Type = InterfaceTypeClient
@@ -369,6 +373,7 @@ func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
ClientAddress: CidrsToString(p.Interface.Addresses), ClientAddress: CidrsToString(p.Interface.Addresses),
ClientDns: p.Interface.DnsStr.GetValue(), ClientDns: p.Interface.DnsStr.GetValue(),
ClientKeepalive: p.PersistentKeepalive.GetValue(), ClientKeepalive: p.PersistentKeepalive.GetValue(),
Dynamic: p.IsDynamic,
} }
pp.SetExtras(extras) pp.SetExtras(extras)
case ControllerTypeLocal: case ControllerTypeLocal: