Compare commits

..

2 Commits

Author SHA1 Message Date
Christoph
7bfbe12d5b change default check-time to 180s 2026-03-19 12:42:08 +01:00
Christoph Haas
c3defbf675 fix: support configurable rekey timeout interval for peer connectivity tracking (#641) 2026-03-18 21:55:45 +01:00
43 changed files with 1960 additions and 2714 deletions

View File

@@ -44,7 +44,7 @@ jobs:
- name: Run chart-testing (lint)
run: ct lint --config ct.yaml
- uses: nolar/setup-k3d-k3s@8bf8d22160e8b1d184dcb780e390d6952a7eec65 # v1.0.10
- uses: nolar/setup-k3d-k3s@293b8e5822a20bc0d5bcdd4826f1a665e72aba96 # v1.0.9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -62,7 +62,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -21,10 +21,10 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Get Version
shell: bash
@@ -32,14 +32,14 @@ jobs:
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -47,7 +47,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: |
wgportal/wg-portal
@@ -68,7 +68,7 @@ jobs:
type=semver,pattern=v{{major}}
- name: Build and push Docker image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@@ -80,7 +80,7 @@ jobs:
BUILD_VERSION=${{ env.BUILD_VERSION }}
- name: Export binaries from images
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
@@ -110,12 +110,12 @@ jobs:
contents: write
steps:
- name: Download binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: binaries
- name: Create GitHub Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
files: 'wg-portal_linux*'
generate_release_notes: true

View File

@@ -80,7 +80,7 @@ func main() {
internal.AssertNoError(err)
auditRecorder.StartBackgroundJobs(ctx)
userManager, err := users.NewUserManager(cfg, eventBus, database, database, database)
userManager, err := users.NewUserManager(cfg, eventBus, database, database)
internal.AssertNoError(err)
userManager.StartBackgroundJobs(ctx)

View File

@@ -86,9 +86,6 @@ auth:
memberof: memberOf
admin_group: CN=WireGuardAdmins,OU=Some-OU,DC=COMPANY,DC=LOCAL
registration_enabled: true
# Restrict interface access based on LDAP filters
interface_filter:
wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)"
log_user_info: true
```

View File

@@ -742,16 +742,6 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
- **Important**: The `login_filter` must always be a valid LDAP filter. It should at most return one user.
If the filter returns multiple or no users, the login will fail.
#### `interface_filter`
- **Default:** *(empty)*
- **Description:** A map of LDAP filters to restrict access to specific WireGuard interfaces. The map keys are the interface identifiers (e.g., `wg0`), and the values are LDAP filters. Only users matching the filter will be allowed to provision peers for the respective interface.
For example:
```yaml
interface_filter:
wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)"
wg1: "(description=special-access)"
```
#### `admin_group`
- **Default:** *(empty)*
- **Description:** A specific LDAP group whose members are considered administrators in WireGuard Portal.

View File

@@ -147,26 +147,6 @@ You can map users to admin roles based on their group membership in the LDAP ser
The `admin_group` property defines the distinguished name of the group that is allowed to log in as admin.
All groups that are listed in the `memberof` attribute of the user will be checked against this group. If one of the groups matches, the user is granted admin access.
### Interface-specific Provisioning Filters
You can restrict which users are allowed to provision peers for specific WireGuard interfaces by setting the `interface_filter` property.
This property is a map where each key corresponds to a WireGuard interface identifier, and the value is an LDAP filter.
A user will only be able to see and provision peers for an interface if they match the specified LDAP filter for that interface.
Example:
```yaml
auth:
ldap:
- provider_name: "ldap1"
# ... other settings
interface_filter:
wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)"
wg1: "(department=IT)"
```
This feature works by materializing the list of authorized users for each interface during the periodic LDAP synchronization.
Even if a user bypasses the UI, the backend will enforce these restrictions at the service layer.
## User Synchronization

View File

@@ -44,11 +44,3 @@ All peers associated with that user will also be disabled.
If you want a user and its peers to be automatically re-enabled once they are found in LDAP again, set the `auto_re_enable` property to `true`.
This will only re-enable the user if they were disabled by the synchronization process. Manually disabled users will not be re-enabled.
##### Interface-specific Access Materialization
If `interface_filter` is configured in the LDAP provider, the synchronization process will evaluate these filters for each enabled user.
The results are materialized in the `interfaces` table of the database in a hidden field.
This materialized list is used by the backend to quickly determine if a user has permission to provision peers for a specific interface, without having to query the LDAP server for every request.
The list is refreshed every time the LDAP synchronization runs.
For more details on how to configure these filters, see the [Authentication](./authentication.md#interface-specific-provisioning-filters) section.

View File

@@ -14,7 +14,7 @@
let WGPORTAL_SITE_TITLE="WireGuard Portal";
let WGPORTAL_SITE_COMPANY_NAME="WireGuard Portal";
</script>
<script src="/api/v0/config/frontend.js" vite-ignore></script>
<script src="/api/v0/config/frontend.js"></script>
</head>
<body class="d-flex flex-column min-vh-100">
<noscript>

File diff suppressed because it is too large Load Diff

View File

@@ -9,28 +9,28 @@
},
"dependencies": {
"@fontsource/nunito-sans": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.2.0",
"@fortawesome/fontawesome-free": "^7.1.0",
"@kyvg/vue3-notification": "^3.4.2",
"@popperjs/core": "^2.11.8",
"@simplewebauthn/browser": "^13.3.0",
"@vojtechlanka/vue-tags-input": "^3.1.2",
"@simplewebauthn/browser": "^13.2.2",
"@vojtechlanka/vue-tags-input": "^3.1.1",
"bootstrap": "^5.3.8",
"bootswatch": "^5.3.8",
"cidr-tools": "^11.3.2",
"cidr-tools": "^11.0.3",
"flag-icons": "^7.5.0",
"ip-address": "^10.1.0",
"is-cidr": "^6.0.3",
"is-cidr": "^6.0.1",
"is-ip": "^5.0.1",
"pinia": "^3.0.4",
"prismjs": "^1.30.0",
"vue": "^3.5.31",
"vue-i18n": "^11.3.0",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^5.0.4"
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"sass-embedded": "^1.98.0",
"vite": "^8.0.3"
"@vitejs/plugin-vue": "^6.0.2",
"sass-embedded": "^1.93.3",
"vite": "^7.2.7"
}
}

View File

@@ -315,7 +315,6 @@ async function applyPeerDefaults() {
async function del() {
if (isDeleting.value) return
if (!confirm(t('modals.interface-edit.confirm-delete', {id: selectedInterface.value.Identifier}))) return
isDeleting.value = true
try {
await interfaces.DeleteInterface(selectedInterface.value.Identifier)

View File

@@ -26,13 +26,13 @@
display:block;
}
.modal.show {
opacity: 1.0;
opacity: 1;
}
.modal-backdrop {
background-color: rgba(0,0,0,0.6) !important;
}
.modal-backdrop.show {
opacity: 1.0 !important;
opacity: 1 !important;
}
</style>

View File

@@ -294,7 +294,6 @@ async function save() {
async function del() {
if (isDeleting.value) return
if (!confirm(t('modals.peer-edit.confirm-delete', {id: selectedPeer.value.Identifier}))) return
isDeleting.value = true
try {
await peers.DeletePeer(selectedPeer.value.Identifier)

View File

@@ -114,7 +114,6 @@ async function save() {
async function del() {
if (isDeleting.value) return
if (!confirm(t('modals.user-edit.confirm-delete', {id: selectedUser.value.Identifier}))) return
isDeleting.value = true
try {
await users.DeleteUser(selectedUser.value.Identifier)

View File

@@ -382,8 +382,7 @@
"persist-local-changes": {
"label": "Lokale Änderungen speichern"
},
"sync-warning": "Um diesen synchronisierten Benutzer zu bearbeiten, aktivieren Sie die lokale Änderungsspeicherung. Andernfalls werden Ihre Änderungen bei der nächsten Synchronisierung überschrieben.",
"confirm-delete": "Benutzer '{id}' wirklich löschen?"
"sync-warning": "Um diesen synchronisierten Benutzer zu bearbeiten, aktivieren Sie die lokale Änderungsspeicherung. Andernfalls werden Ihre Änderungen bei der nächsten Synchronisierung überschrieben."
},
"interface-view": {
"headline": "Konfiguration für Schnittstelle:"
@@ -504,8 +503,7 @@
"placeholder": "Persistentes Keepalive (0 = Standard)"
}
},
"button-apply-defaults": "Peer-Standardeinstellungen anwenden",
"confirm-delete": "Interface '{id}' wirklich löschen?"
"button-apply-defaults": "Peer-Standardeinstellungen anwenden"
},
"peer-view": {
"headline-peer": "Peer:",
@@ -627,8 +625,7 @@
},
"expires-at": {
"label": "Ablaufdatum"
},
"confirm-delete": "Peer '{id}' wirklich löschen?"
}
},
"peer-multi-create": {
"headline-peer": "Mehrere Peers erstellen",

View File

@@ -271,16 +271,16 @@
"headline-preshared-key": "New Preshared Key",
"button-generate": "Generate",
"private-key": {
"label": "Private Key",
"placeholder": "The private key"
"label": "Private Key",
"placeholder": "The private key"
},
"public-key": {
"label": "Public Key",
"placeholder": "The public key"
"label": "Public Key",
"placeholder": "The public key"
},
"preshared-key": {
"label": "Preshared Key",
"placeholder": "The pre-shared key"
"label": "Preshared Key",
"placeholder": "The pre-shared key"
}
},
"calculator": {
@@ -289,18 +289,18 @@
"headline-allowed-ip": "New Allowed IPs",
"button-exclude-private": "Exclude Private IP Ranges",
"allowed-ip": {
"label": "Allowed IPs",
"placeholder": "0.0.0.0/0, ::/0",
"empty": "Value cannot be empty"
"label": "Allowed IPs",
"placeholder": "0.0.0.0/0, ::/0",
"empty": "Value cannot be empty"
},
"dissallowed-ip": {
"label": "Disallowed IPs",
"placeholder": "10.0.0.0/8, 192.168.0.0/16",
"invalid": "Invalid address: {addr}"
"label": "Disallowed IPs",
"placeholder": "10.0.0.0/8, 192.168.0.0/16",
"invalid": "Invalid address: {addr}"
},
"new-allowed-ip": {
"label": "Allowed IPs",
"placeholder": ""
"label": "Allowed IPs",
"placeholder": ""
}
},
"modals": {
@@ -382,8 +382,7 @@
"persist-local-changes": {
"label": "Persist local changes"
},
"sync-warning": "To modify this synchronized user, enable local change persistence. Otherwise, your changes will be overwritten during the next synchronization.",
"confirm-delete": "Are you sure you want to delete user '{id}'?"
"sync-warning": "To modify this synchronized user, enable local change persistence. Otherwise, your changes will be overwritten during the next synchronization."
},
"interface-view": {
"headline": "Config for Interface:"
@@ -504,8 +503,8 @@
"placeholder": "Persistent Keepalive (0 = default)"
}
},
"button-apply-defaults": "Apply Peer Defaults",
"confirm-delete": "Are you sure you want to delete interface '{id}'?"
"button-apply-defaults": "Apply Peer Defaults"
},
"peer-view": {
"headline-peer": "Peer:",
@@ -627,8 +626,7 @@
},
"expires-at": {
"label": "Expiry date"
},
"confirm-delete": "Are you sure you want to delete peer '{id}'?"
}
},
"peer-multi-create": {
"headline-peer": "Create multiple peers",

View File

@@ -365,8 +365,7 @@
},
"admin": {
"label": "Es administrador"
},
"confirm-delete": "Seguro que desea eliminar el usuario '{id}'?"
}
},
"interface-view": {
"headline": "Configuración de la interfaz:"
@@ -494,8 +493,7 @@
"placeholder": "Keepalive Persistente (0 = por defecto)"
}
},
"button-apply-defaults": "Aplicar Valores Predeterminados de peers",
"confirm-delete": "Seguro que desea eliminar la interfaz '{id}'?"
"button-apply-defaults": "Aplicar Valores Predeterminados de peers"
},
"peer-view": {
"headline-peer": "Peer:",
@@ -615,8 +613,7 @@
},
"expires-at": {
"label": "Fecha de expiración"
},
"confirm-delete": "Seguro que desea eliminar el par '{id}'?"
}
},
"peer-multi-create": {
"headline-peer": "Crear múltiples peers",

View File

@@ -126,7 +126,9 @@
"peer-expiring": "Le pair expire le",
"peer-connected": "Connecté",
"peer-not-connected": "Non connecté",
"peer-handshake": "Dernière négociation :"
"peer-handshake": "Dernière négociation :",
"button-show-peer": "Afficher le pair",
"button-edit-peer": "Modifier le pair"
},
"users": {
"headline": "Administration des utilisateurs",
@@ -262,8 +264,7 @@
},
"admin": {
"label": "Est Admin"
},
"confirm-delete": "Voulez-vous vraiment supprimer l'utilisateur \"{id}\" ?"
}
},
"interface-view": {
"headline": "Configuration pour l'interface :"
@@ -376,8 +377,7 @@
"placeholder": "Persistent Keepalive (0 = par défaut)"
}
},
"button-apply-defaults": "Appliquer les valeurs par défaut des pairs",
"confirm-delete": "Voulez-vous vraiment supprimer l'interface \"{id}\" ?"
"button-apply-defaults": "Appliquer les valeurs par défaut des pairs"
},
"peer-view": {
"headline-peer": "Pair :",
@@ -493,8 +493,7 @@
},
"expires-at": {
"label": "Date d'expiration"
},
"confirm-delete": "Voulez-vous vraiment supprimer le pair \"{id}\" ?"
}
},
"peer-multi-create": {
"headline-peer": "Créer plusieurs pairs",

View File

@@ -282,7 +282,6 @@
"label": "관리자 여부"
}
},
"confirm-delete": "사용자 '{id}'를 삭제하시겠습니까?",
"interface-view": {
"headline": "인터페이스 구성:"
},
@@ -394,8 +393,7 @@
"placeholder": "영구 Keepalive (0 = 기본값)"
}
},
"button-apply-defaults": "피어 기본값 적용",
"confirm-delete": "인터페이스 '{id}'를 삭제하시겠습니까?"
"button-apply-defaults": "피어 기본값 적용"
},
"peer-view": {
"headline-peer": "피어:",
@@ -511,8 +509,7 @@
},
"expires-at": {
"label": "만료 날짜"
},
"confirm-delete": "피어 '{id}'를 삭제하시겠습니까?"
}
},
"peer-multi-create": {
"headline-peer": "여러 피어 생성",

View File

@@ -300,8 +300,7 @@
},
"admin": {
"label": "É Administrador"
},
"confirm-delete": "Tem certeza que deseja excluir o utilizador '{id}'?"
}
},
"interface-view": {
"headline": "Configuração para a Interface:"
@@ -414,8 +413,7 @@
"placeholder": "Keepalive persistente (0 = padrão)"
}
},
"button-apply-defaults": "Aplicar Padrões de Peer",
"confirm-delete": "Tem certeza que deseja excluir a interface '{id}'?"
"button-apply-defaults": "Aplicar Padrões de Peer"
},
"peer-view": {
"headline-peer": "Peer:",
@@ -532,8 +530,7 @@
},
"expires-at": {
"label": "Data de expiração"
},
"confirm-delete": "Tem certeza que deseja excluir o par '{id}'?"
}
},
"peer-multi-create": {
"headline-peer": "Criar múltiplos peers",

View File

@@ -259,16 +259,16 @@
"headline-preshared-key": "Новый общий ключ",
"button-generate": "Генерировать",
"private-key": {
"label": "Приватный ключ",
"placeholder": "Приватный ключ"
"label": "Приватный ключ",
"placeholder": "Приватный ключ"
},
"public-key": {
"label": "Публичный ключ",
"placeholder": "Публичный ключ"
"label": "Публичный ключ",
"placeholder": "Публичный ключ"
},
"preshared-key": {
"label": "Общий ключ",
"placeholder": "Общий ключ"
"label": "Общий ключ",
"placeholder": "Общий ключ"
}
},
"calculator": {
@@ -277,18 +277,18 @@
"headline-allowed-ip": "Новые разрешенные IP-адреса",
"button-exclude-private": "Исключить частные диапазоны IP-адресов",
"allowed-ip": {
"label": "Разрешенные IP-адреса",
"placeholder": "0.0.0.0/0, ::/0",
"empty": "Поле ввода не должно быть пустым"
"label": "Разрешенные IP-адреса",
"placeholder": "0.0.0.0/0, ::/0",
"empty": "Поле ввода не должно быть пустым"
},
"dissallowed-ip": {
"label": "Запрещенные IP-адреса",
"placeholder": "10.0.0.0/8, 192.168.0.0/16",
"invalid": "Некорректный адрес: {addr}"
"label": "Запрещенные IP-адреса",
"placeholder": "10.0.0.0/8, 192.168.0.0/16",
"invalid": "Некорректный адрес: {addr}"
},
"new-allowed-ip": {
"label": "Разрешенные IP-адреса",
"placeholder": ""
"label": "Разрешенные IP-адреса",
"placeholder": ""
}
},
"modals": {
@@ -366,8 +366,7 @@
},
"admin": {
"label": "Является администратором"
},
"confirm-delete": "Вы уверены, что хотите удалить пользователя «{id}»?"
}
},
"interface-view": {
"headline": "Конфигурация интерфейса:"
@@ -485,8 +484,7 @@
"placeholder": "Постоянное поддержание активности (0 = значение по умолчанию)"
}
},
"button-apply-defaults": "Применить настройки пира по умолчанию",
"confirm-delete": "Вы уверены, что хотите удалить интерфейс «{id}»?"
"button-apply-defaults": "Применить настройки пира по умолчанию"
},
"peer-view": {
"headline-peer": "Пир:",
@@ -607,8 +605,7 @@
},
"expires-at": {
"label": "Дата истечения срока действия"
},
"confirm-delete": "Вы уверены, что хотите удалить пир «{id}»?"
}
},
"peer-multi-create": {
"headline-peer": "Создать несколько узлов",

View File

@@ -151,6 +151,7 @@
"admin": "Користувач має адміністративні привілеї",
"no-admin": "Користувач не має адміністративних привілеїв"
},
"profile": {
"headline": "Мої VPN-піри",
"table-heading": {
@@ -188,6 +189,7 @@
"api-link": "Документація API"
}
},
"modals": {
"user-view": {
"headline": "Обліковий запис користувача:",
@@ -262,8 +264,7 @@
},
"admin": {
"label": "Адміністратор"
},
"confirm-delete": "Ви впевнені, що хочете видалити користувача «{id}»?"
}
},
"interface-view": {
"headline": "Конфігурація для інтерфейсу:"
@@ -376,8 +377,7 @@
"placeholder": "Постійний Keepalive (0 = за замовчуванням)"
}
},
"button-apply-defaults": "Застосувати значення за замовчуванням для пірів",
"confirm-delete": "Ви впевнені, що хочете видалити інтерфейс «{id}»?"
"button-apply-defaults": "Застосувати значення за замовчуванням для пірів"
},
"peer-view": {
"headline-peer": "Пір:",
@@ -493,8 +493,7 @@
},
"expires-at": {
"label": "Дата закінчення терміну дії"
},
"confirm-delete": "Ви впевнені, що хочете видалити пір «{id}»?"
}
},
"peer-multi-create": {
"headline-peer": "Створити декілька пір",

View File

@@ -240,8 +240,7 @@
},
"admin": {
"label": "Là Quản trị viên"
},
"confirm-delete": "Ban co chac muon xoa nguoi dung '{id}' khong?"
}
},
"interface-view": {
"headline": "Cấu hình cho Giao diện:"
@@ -354,8 +353,8 @@
"placeholder": "Giữ kết nối liên tục (0 = mặc định)"
}
},
"button-apply-defaults": "Áp dụng Cài đặt Mặc định của Peer",
"confirm-delete": "Ban co chac muon xoa giao dien '{id}' khong?"
"button-apply-defaults": "Áp dụng Cài đặt Mặc định của Peer"
},
"peer-view": {
"headline-peer": "Peer:",
@@ -471,8 +470,7 @@
},
"expires-at": {
"label": "Ngày hết hạn"
},
"confirm-delete": "Ban co chac muon xoa peer '{id}' khong?"
}
},
"peer-multi-create": {
"headline-peer": "Tạo nhiều peer",

View File

@@ -242,7 +242,6 @@
"label": "管理员"
}
},
"confirm-delete": "确定要删除用户“{id}”吗?",
"interface-view": {
"headline": "接口配置: "
},
@@ -354,8 +353,7 @@
"placeholder": "持久保持连接 (0 = 默认)"
}
},
"button-apply-defaults": "应用节点默认值",
"confirm-delete": "确定要删除接口“{id}”吗?"
"button-apply-defaults": "应用节点默认值"
},
"peer-view": {
"headline-peer": "节点: ",
@@ -471,8 +469,7 @@
},
"expires-at": {
"label": "过期日期"
},
"confirm-delete": "确定要删除对等点“{id}”吗?"
}
},
"peer-multi-create": {
"headline-peer": "创建多个节点",

View File

@@ -1,6 +1,7 @@
import {createRouter, createWebHashHistory} from 'vue-router'
import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue'
import InterfaceView from '../views/InterfaceView.vue'
import {authStore} from '@/stores/auth'
import {securityStore} from '@/stores/security'
@@ -19,6 +20,11 @@ const router = createRouter({
name: 'login',
component: LoginView
},
{
path: '/interface',
name: 'interface',
component: InterfaceView
},
{
path: '/interfaces',
name: 'interfaces',

View File

@@ -74,7 +74,6 @@ export const profileStore = defineStore('profile', {
},
hasStatistics: (state) => state.statsEnabled,
CountInterfaces: (state) => state.interfaces.length,
HasInterface: (state) => (id) => state.interfaces.some((i) => i.Identifier === id),
},
actions: {
afterPageSizeChange() {

View File

@@ -80,8 +80,6 @@ onMounted(async () => {
<div class="col-12 col-lg-5">
<h2 class="mt-2">{{ $t('profile.headline') }}</h2>
</div>
<div class="col-12 col-lg-3 text-lg-end" v-if="!settings.Setting('SelfProvisioning') || profile.CountInterfaces===0">
</div>
<div class="col-12 col-lg-4 text-lg-end">
<div class="form-group d-inline">
<div class="input-group mb-3">
@@ -92,8 +90,8 @@ onMounted(async () => {
</div>
</div>
</div>
<div class="col-12 col-lg-3 text-lg-end" v-if="settings.Setting('SelfProvisioning') && profile.CountInterfaces>0">
<div class="form-group">
<div class="col-12 col-lg-3 text-lg-end">
<div class="form-group" v-if="settings.Setting('SelfProvisioning')">
<div class="input-group mb-3">
<button class="btn btn-primary" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId = '#NEW#'">
<i class="fa fa-plus me-1"></i><i class="fa fa-user"></i>
@@ -162,7 +160,8 @@ onMounted(async () => {
</td>
<td v-if="profile.hasStatistics">
<div v-if="profile.Statistics(peer.Identifier).IsConnected">
<span class="badge rounded-pill bg-success" :title="$t('profile.peer-connected')"><i class="fa-solid fa-link"></i></span> <small class="text-muted" :title="$t('interfaces.peer-handshake') + ' ' + profile.Statistics(peer.Identifier).LastHandshake"><i class="fa-solid fa-circle-info"></i></small>
<span class="badge rounded-pill bg-success"><i class="fa-solid fa-link"></i></span>
<span :title="profile.Statistics(peer.Identifier).LastHandshake">{{ $t('profile.peer-connected') }}</span>
</div>
<div v-else>
<span class="badge rounded-pill bg-light"><i class="fa-solid fa-link-slash"></i></span>
@@ -175,7 +174,7 @@ onMounted(async () => {
<td class="text-center">
<a href="#" :title="$t('profile.button-show-peer')" @click.prevent="viewedPeerId = peer.Identifier"><i
class="fas fa-eye me-2"></i></a>
<a href="#" :title="$t('profile.button-edit-peer')" @click.prevent="editPeerId = peer.Identifier" v-if="settings.Setting('SelfProvisioning') && profile.HasInterface(peer.InterfaceIdentifier)"><i
<a href="#" :title="$t('profile.button-edit-peer')" @click.prevent="editPeerId = peer.Identifier"><i
class="fas fa-cog"></i></a>
</td>
</tr>

18
go.mod
View File

@@ -7,10 +7,10 @@ require (
github.com/alexedwards/scs/v2 v2.9.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-pkgz/routegroup v1.6.0
github.com/go-playground/validator/v10 v10.30.1
github.com/go-webauthn/webauthn v0.16.1
github.com/go-webauthn/webauthn v0.16.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/prometheus-community/pro-bing v0.8.0
@@ -22,9 +22,9 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.49.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.42.0
golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sys v0.41.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
@@ -61,7 +61,7 @@ require (
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-test/deep v1.1.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.2 // indirect
github.com/go-webauthn/x v0.2.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
@@ -95,9 +95,9 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/protobuf v1.36.11 // indirect

36
go.sum
View File

@@ -60,8 +60,8 @@ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
@@ -105,10 +105,10 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU=
github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ=
github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=
github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=
github.com/go-webauthn/webauthn v0.16.0 h1:A9BkfYIwWAMPSQCbM2HoWqo6JO5LFI8aqYAzo6nW7AY=
github.com/go-webauthn/webauthn v0.16.0/go.mod h1:hm9RS/JNYeUu3KqGbzqlnHClhDGCZzTZlABjathwnN0=
github.com/go-webauthn/x v0.2.1 h1:/oB8i0FhSANuoN+YJF5XHMtppa7zGEYaQrrf6ytotjc=
github.com/go-webauthn/x v0.2.1/go.mod h1:Wm0X0zXkzznit4gHj4m82GiBZRMEm+TDUIoJWIQLsE4=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
@@ -274,8 +274,8 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -303,10 +303,10 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -314,8 +314,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -336,8 +336,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -366,8 +366,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View File

@@ -232,19 +232,21 @@ func (r *SqlRepo) migrate() error {
slog.Debug("running migration: interface status", "result", r.db.AutoMigrate(&domain.InterfaceStatus{}))
slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{}))
var existingSysStat SysStat
var err error
existingSysStat := SysStat{}
r.db.Order("schema_version desc").First(&existingSysStat) // get latest version
// Migration: 0 --> 1
if existingSysStat.SchemaVersion == 0 {
const schemaVersion = 1
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
}
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
slog.Debug("sys-stat entry written", "schema_version", schemaVersion)
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
}
// Migration: 1 --> 2
@@ -260,10 +262,14 @@ func (r *SqlRepo) migrate() error {
}
slog.Debug("migrated interface create_default_peer flags", "schema_version", schemaVersion)
}
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
}
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
}
// Migration: 2 --> 3
@@ -301,45 +307,19 @@ func (r *SqlRepo) migrate() error {
if err != nil {
return fmt.Errorf("failed to migrate to multi-auth: %w", err)
}
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
}
}
// Migration: 3 --> 4
if existingSysStat.SchemaVersion == 3 {
const schemaVersion = 4
cutoff := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
// Fix zero created_at timestamps for users. Set the to the last known update timestamp.
err := r.db.Model(&domain.User{}).Where("created_at < ?", cutoff).
Update("created_at", gorm.Expr("updated_at")).Error
if err != nil {
slog.Warn("failed to fix zero created_at for users", "error", err)
}
slog.Debug("fixed zero created_at timestamps for users", "schema_version", schemaVersion)
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
}
return nil
}
func (r *SqlRepo) addMigration(schemaVersion uint64) (SysStat, error) {
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
}
if err := r.db.Create(&sysStat).Error; err != nil {
return SysStat{}, fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
return sysStat, nil
}
// region interfaces
// GetInterface returns the interface with the given id.
@@ -502,7 +482,7 @@ func (r *SqlRepo) getOrCreateInterface(
Identifier: id,
}
err := tx.Preload("Addresses").Attrs(interfaceDefaults).FirstOrCreate(&in, id).Error
err := tx.Attrs(interfaceDefaults).FirstOrCreate(&in, id).Error
if err != nil {
return nil, err
}
@@ -711,7 +691,7 @@ func (r *SqlRepo) getOrCreatePeer(ui *domain.ContextUserInfo, tx *gorm.DB, id do
Identifier: id,
}
err := tx.Preload("Addresses").Attrs(interfaceDefaults).FirstOrCreate(&peer, id).Error
err := tx.Attrs(interfaceDefaults).FirstOrCreate(&peer, id).Error
if err != nil {
return nil, err
}

View File

@@ -1,168 +0,0 @@
package adapters
import (
"context"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
return db
}
func TestUpsertUser_SetsCreatedAtWhenZero(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ui := domain.SystemAdminContextUserInfo()
user := &domain.User{
Identifier: "test-user",
Email: "test@example.com",
// CreatedAt is zero
}
err := repo.upsertUser(ui, db, user)
require.NoError(t, err)
assert.False(t, user.CreatedAt.IsZero(), "CreatedAt should be set when it was zero")
assert.Equal(t, ui.UserId(), user.UpdatedBy, "UpdatedBy should be set when it was empty")
assert.WithinDuration(t, user.UpdatedAt, user.CreatedAt, time.Second,
"CreatedAt should be close to UpdatedAt for new user")
}
func TestUpsertUser_PreservesExistingCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ui := domain.SystemAdminContextUserInfo()
originalTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
user := &domain.User{
Identifier: "test-user",
Email: "test@example.com",
BaseModel: domain.BaseModel{
CreatedAt: originalTime,
CreatedBy: "original-creator",
},
}
err := repo.upsertUser(ui, db, user)
require.NoError(t, err)
assert.Equal(t, originalTime, user.CreatedAt, "CreatedAt should not be overwritten")
assert.Equal(t, "original-creator", user.CreatedBy, "CreatedBy should not be overwritten")
}
func TestSaveUser_NewUserGetsCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ctx := domain.SetUserInfo(context.Background(), domain.SystemAdminContextUserInfo())
before := time.Now().Add(-time.Second)
err := repo.SaveUser(ctx, "new-user", func(u *domain.User) (*domain.User, error) {
u.Email = "new@example.com"
return u, nil
})
require.NoError(t, err)
var saved domain.User
require.NoError(t, db.First(&saved, "identifier = ?", "new-user").Error)
assert.False(t, saved.CreatedAt.IsZero(), "CreatedAt should not be zero")
assert.True(t, saved.CreatedAt.After(before), "CreatedAt should be recent")
assert.NotEmpty(t, saved.CreatedBy, "CreatedBy should be set")
}
func TestMigration_FixesZeroCreatedAt(t *testing.T) {
db := newTestDB(t)
// Manually create tables and seed schema version 3
require.NoError(t, db.AutoMigrate(
&SysStat{},
&domain.User{},
&domain.UserAuthentication{},
&domain.Interface{},
&domain.Cidr{},
&domain.Peer{},
&domain.AuditEntry{},
&domain.UserWebauthnCredential{},
))
// Insert schema versions 1, 2, 3 so migration starts at 3
for v := uint64(1); v <= 3; v++ {
require.NoError(t, db.Create(&SysStat{SchemaVersion: v, MigratedAt: time.Now()}).Error)
}
updatedAt := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC)
// Insert a user with zero created_at but valid updated_at
require.NoError(t, db.Exec(
"INSERT INTO users (identifier, email, created_at, updated_at) VALUES (?, ?, ?, ?)",
"zero-user", "zero@example.com", time.Time{}, updatedAt,
).Error)
// Run migration
repo := &SqlRepo{db: db, cfg: &config.Config{}}
require.NoError(t, repo.migrate())
// Verify created_at was backfilled from updated_at
var user domain.User
require.NoError(t, db.First(&user, "identifier = ?", "zero-user").Error)
assert.Equal(t, updatedAt, user.CreatedAt, "created_at should be backfilled from updated_at")
// Verify schema version advanced to 4
var latest SysStat
require.NoError(t, db.Order("schema_version DESC").First(&latest).Error)
assert.Equal(t, uint64(4), latest.SchemaVersion)
}
func TestMigration_DoesNotTouchValidCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(
&SysStat{},
&domain.User{},
&domain.UserAuthentication{},
&domain.Interface{},
&domain.Cidr{},
&domain.Peer{},
&domain.AuditEntry{},
&domain.UserWebauthnCredential{},
))
for v := uint64(1); v <= 3; v++ {
require.NoError(t, db.Create(&SysStat{SchemaVersion: v, MigratedAt: time.Now()}).Error)
}
createdAt := time.Date(2024, 3, 1, 8, 0, 0, 0, time.UTC)
updatedAt := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC)
require.NoError(t, db.Exec(
"INSERT INTO users (identifier, email, created_at, updated_at) VALUES (?, ?, ?, ?)",
"valid-user", "valid@example.com", createdAt, updatedAt,
).Error)
repo := &SqlRepo{db: db, cfg: &config.Config{}}
require.NoError(t, repo.migrate())
var user domain.User
require.NoError(t, db.First(&user, "identifier = ?", "valid-user").Error)
assert.Equal(t, createdAt, user.CreatedAt, "valid created_at should not be modified")
}

View File

@@ -1,73 +0,0 @@
package adapters
import (
"context"
"reflect"
"testing"
"github.com/glebarez/sqlite"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func init() {
schema.RegisterSerializer("encstr", dummySerializer{})
}
type dummySerializer struct{}
func (dummySerializer) Scan(ctx context.Context, field *schema.Field, dst reflect.Value, dbValue any) error {
return nil
}
func (dummySerializer) Value(ctx context.Context, field *schema.Field, dst reflect.Value, fieldValue any) (any, error) {
if fieldValue == nil {
return nil, nil
}
if v, ok := fieldValue.(string); ok {
return v, nil
}
if v, ok := fieldValue.(domain.PreSharedKey); ok {
return string(v), nil
}
return fieldValue, nil
}
func TestSqlRepo_SaveInterface_Simple(t *testing.T) {
// Initialize in-memory database
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
// Migrate only what's needed for this test (avoids Peer and its encstr serializer)
require.NoError(t, db.AutoMigrate(&domain.Interface{}, &domain.Cidr{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ctx := domain.SetUserInfo(context.Background(), domain.SystemAdminContextUserInfo())
ifaceId := domain.InterfaceIdentifier("wg0")
// 1. Create an interface with one address
addr, _ := domain.CidrFromString("10.0.0.1/24")
initialIface := &domain.Interface{
Identifier: ifaceId,
Addresses: []domain.Cidr{addr},
}
require.NoError(t, db.Create(initialIface).Error)
// 2. Perform a "partial" update using SaveInterface (this is the buggy path)
err = repo.SaveInterface(ctx, ifaceId, func(in *domain.Interface) (*domain.Interface, error) {
in.DisplayName = "New Name"
return in, nil
})
require.NoError(t, err)
// 3. Verify that the address was NOT deleted
var finalIface domain.Interface
require.NoError(t, db.Preload("Addresses").First(&finalIface, "identifier = ?", ifaceId).Error)
require.Equal(t, "New Name", finalIface.DisplayName)
require.Len(t, finalIface.Addresses, 1, "Address list should still have 1 entry!")
require.Equal(t, "10.0.0.1/24", finalIface.Addresses[0].Cidr)
}

View File

@@ -30,7 +30,7 @@ type MetricsServer struct {
// Wireguard metrics labels
var (
ifaceLabels = []string{"interface"}
peerLabels = []string{"interface", "addresses", "id", "name", "user"}
peerLabels = []string{"interface", "addresses", "id", "name"}
)
// NewMetricsServer returns a new prometheus server
@@ -126,7 +126,6 @@ func (m *MetricsServer) UpdatePeerMetrics(peer *domain.Peer, status domain.PeerS
peer.Interface.AddressStr(),
string(status.PeerId),
peer.DisplayName,
string(peer.UserIdentifier),
}
if status.LastHandshake != nil {

View File

@@ -90,12 +90,6 @@ func (m Manager) synchronizeLdapUsers(ctx context.Context, provider *config.Ldap
}
}
// Update interface allowed users based on LDAP filters
err = m.updateInterfaceLdapFilters(ctx, conn, provider)
if err != nil {
return err
}
return nil
}
@@ -243,59 +237,3 @@ func (m Manager) disableMissingLdapUsers(
return nil
}
func (m Manager) updateInterfaceLdapFilters(
ctx context.Context,
conn *ldap.Conn,
provider *config.LdapProvider,
) error {
if len(provider.InterfaceFilter) == 0 {
return nil // nothing to do if no interfaces are configured for this provider
}
for ifaceName, groupFilter := range provider.InterfaceFilter {
ifaceId := domain.InterfaceIdentifier(ifaceName)
// Combined filter: user must match the provider's base SyncFilter AND the interface's LdapGroupFilter
combinedFilter := fmt.Sprintf("(&(%s)(%s))", provider.SyncFilter, groupFilter)
rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, combinedFilter, &provider.FieldMap)
if err != nil {
slog.Error("failed to find users for interface filter",
"interface", ifaceId,
"provider", provider.ProviderName,
"error", err)
continue
}
matchedUserIds := make([]domain.UserIdentifier, 0, len(rawUsers))
for _, rawUser := range rawUsers {
userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, provider.FieldMap.UserIdentifier, ""))
if userId != "" {
matchedUserIds = append(matchedUserIds, userId)
}
}
// Save the interface
err = m.interfaces.SaveInterface(ctx, ifaceId, func(i *domain.Interface) (*domain.Interface, error) {
if i.LdapAllowedUsers == nil {
i.LdapAllowedUsers = make(map[string][]domain.UserIdentifier)
}
i.LdapAllowedUsers[provider.ProviderName] = matchedUserIds
return i, nil
})
if err != nil {
slog.Error("failed to save interface ldap allowed users",
"interface", ifaceId,
"provider", provider.ProviderName,
"error", err)
} else {
slog.Debug("updated interface ldap allowed users",
"interface", ifaceId,
"provider", provider.ProviderName,
"matched_count", len(matchedUserIds))
}
}
return nil
}

View File

@@ -39,11 +39,6 @@ type PeerDatabaseRepo interface {
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
}
type InterfaceDatabaseRepo interface {
// SaveInterface saves the interface with the given identifier.
SaveInterface(ctx context.Context, id domain.InterfaceIdentifier, updateFunc func(i *domain.Interface) (*domain.Interface, error)) error
}
type EventBus interface {
// Publish sends a message to the message bus.
Publish(topic string, args ...any)
@@ -55,27 +50,22 @@ type EventBus interface {
type Manager struct {
cfg *config.Config
bus EventBus
users UserDatabaseRepo
peers PeerDatabaseRepo
interfaces InterfaceDatabaseRepo
bus EventBus
users UserDatabaseRepo
peers PeerDatabaseRepo
}
// NewUserManager creates a new user manager instance.
func NewUserManager(
cfg *config.Config,
bus EventBus,
users UserDatabaseRepo,
peers PeerDatabaseRepo,
interfaces InterfaceDatabaseRepo,
) (*Manager, error) {
func NewUserManager(cfg *config.Config, bus EventBus, users UserDatabaseRepo, peers PeerDatabaseRepo) (
*Manager,
error,
) {
m := &Manager{
cfg: cfg,
bus: bus,
users: users,
peers: peers,
interfaces: interfaces,
users: users,
peers: peers,
}
return m, nil
}
@@ -533,7 +523,6 @@ func (m Manager) create(ctx context.Context, user *domain.User) (*domain.User, e
}
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
user.CopyCalculatedAttributes(u, false)
return user, nil
})
if err != nil {

View File

@@ -35,7 +35,6 @@ type InterfaceAndPeerDatabaseRepo interface {
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error)
GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr) (map[domain.Cidr][]domain.Cidr, error)
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
}
type WgQuickController interface {

View File

@@ -16,11 +16,6 @@ import (
"github.com/h44z/wg-portal/internal/domain"
)
// GetInterface returns the interface for the given interface identifier.
func (m Manager) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
return m.db.GetInterface(ctx, id)
}
// GetInterfaceAndPeers returns the interface and all peers for the given interface identifier.
func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (
*domain.Interface,
@@ -68,17 +63,12 @@ func (m Manager) GetAllInterfacesAndPeers(ctx context.Context) ([]domain.Interfa
// GetUserInterfaces returns all interfaces that are available for users to create new peers.
// If self-provisioning is disabled, this function will return an empty list.
func (m Manager) GetUserInterfaces(ctx context.Context, userId domain.UserIdentifier) ([]domain.Interface, error) {
// At the moment, there are no interfaces specific to single users, thus the user id is not used.
func (m Manager) GetUserInterfaces(ctx context.Context, _ domain.UserIdentifier) ([]domain.Interface, error) {
if !m.cfg.Core.SelfProvisioningAllowed {
return nil, nil // self-provisioning is disabled - no interfaces for users
}
user, err := m.db.GetUser(ctx, userId)
if err != nil {
slog.Error("failed to load user for interface group verification", "user", userId, "error", err)
return nil, nil // fail closed
}
interfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return nil, fmt.Errorf("unable to load all interfaces: %w", err)
@@ -93,9 +83,6 @@ func (m Manager) GetUserInterfaces(ctx context.Context, userId domain.UserIdenti
if iface.Type != domain.InterfaceTypeServer {
continue // skip client interfaces
}
if !user.IsAdmin && !iface.IsUserAllowed(userId, m.cfg) {
continue // user not allowed due to LDAP group filter
}
userInterfaces = append(userInterfaces, iface.PublicInfo())
}

View File

@@ -6,7 +6,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
@@ -93,126 +92,3 @@ func TestImportPeer_AddressMapping(t *testing.T) {
})
}
}
func (f *mockDB) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
return &domain.User{
Identifier: id,
IsAdmin: false,
}, nil
}
func TestInterface_IsUserAllowed(t *testing.T) {
cfg := &config.Config{
Auth: config.Auth{
Ldap: []config.LdapProvider{
{
ProviderName: "ldap1",
InterfaceFilter: map[string]string{
"wg0": "(memberOf=CN=VPNUsers,...)",
},
},
},
},
}
tests := []struct {
name string
iface domain.Interface
userId domain.UserIdentifier
expect bool
}{
{
name: "Unrestricted interface",
iface: domain.Interface{
Identifier: "wg1",
},
userId: "user1",
expect: true,
},
{
name: "Restricted interface - user allowed",
iface: domain.Interface{
Identifier: "wg0",
LdapAllowedUsers: map[string][]domain.UserIdentifier{
"ldap1": {"user1"},
},
},
userId: "user1",
expect: true,
},
{
name: "Restricted interface - user allowed (at least one match)",
iface: domain.Interface{
Identifier: "wg0",
LdapAllowedUsers: map[string][]domain.UserIdentifier{
"ldap1": {"user2"},
"ldap2": {"user1"},
},
},
userId: "user1",
expect: true,
},
{
name: "Restricted interface - user NOT allowed",
iface: domain.Interface{
Identifier: "wg0",
LdapAllowedUsers: map[string][]domain.UserIdentifier{
"ldap1": {"user2"},
},
},
userId: "user1",
expect: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expect, tt.iface.IsUserAllowed(tt.userId, cfg))
})
}
}
func TestManager_GetUserInterfaces_Filtering(t *testing.T) {
cfg := &config.Config{}
cfg.Core.SelfProvisioningAllowed = true
cfg.Auth.Ldap = []config.LdapProvider{
{
ProviderName: "ldap1",
InterfaceFilter: map[string]string{
"wg_restricted": "(some-filter)",
},
},
}
db := &mockDB{
interfaces: []domain.Interface{
{Identifier: "wg_public", Type: domain.InterfaceTypeServer},
{
Identifier: "wg_restricted",
Type: domain.InterfaceTypeServer,
LdapAllowedUsers: map[string][]domain.UserIdentifier{
"ldap1": {"allowed_user"},
},
},
},
}
m := Manager{
cfg: cfg,
db: db,
}
t.Run("Allowed user sees both", func(t *testing.T) {
ifaces, err := m.GetUserInterfaces(context.Background(), "allowed_user")
assert.NoError(t, err)
assert.Equal(t, 2, len(ifaces))
})
t.Run("Unallowed user sees only public", func(t *testing.T) {
ifaces, err := m.GetUserInterfaces(context.Background(), "other_user")
assert.NoError(t, err)
assert.Equal(t, 1, len(ifaces))
if len(ifaces) > 0 {
assert.Equal(t, domain.InterfaceIdentifier("wg_public"), ifaces[0].Identifier)
}
})
}

View File

@@ -93,10 +93,6 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier)
currentUser := domain.GetUserInfo(ctx)
if err := m.checkInterfaceAccess(ctx, id); err != nil {
return nil, err
}
iface, err := m.db.GetInterface(ctx, id)
if err != nil {
return nil, fmt.Errorf("unable to find interface %s: %w", id, err)
@@ -192,9 +188,6 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
if err := domain.ValidateUserAccessRights(ctx, peer.UserIdentifier); err != nil {
return nil, err
}
if err := m.checkInterfaceAccess(ctx, peer.InterfaceIdentifier); err != nil {
return nil, err
}
}
sessionUser := domain.GetUserInfo(ctx)
@@ -311,10 +304,6 @@ func (m Manager) UpdatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
return nil, err
}
if err := m.checkInterfaceAccess(ctx, existingPeer.InterfaceIdentifier); err != nil {
return nil, err
}
if err := m.validatePeerModifications(ctx, existingPeer, peer); err != nil {
return nil, fmt.Errorf("update not allowed: %w", err)
}
@@ -384,10 +373,6 @@ func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
return err
}
if err := m.checkInterfaceAccess(ctx, peer.InterfaceIdentifier); err != nil {
return err
}
if err := m.validatePeerDeletion(ctx, peer); err != nil {
return fmt.Errorf("delete not allowed: %w", err)
}
@@ -621,22 +606,4 @@ func (m Manager) validatePeerDeletion(ctx context.Context, _ *domain.Peer) error
return nil
}
func (m Manager) checkInterfaceAccess(ctx context.Context, id domain.InterfaceIdentifier) error {
user := domain.GetUserInfo(ctx)
if user.IsAdmin {
return nil
}
iface, err := m.db.GetInterface(ctx, id)
if err != nil {
return fmt.Errorf("failed to get interface %s: %w", id, err)
}
if !iface.IsUserAllowed(user.Id, m.cfg) {
return fmt.Errorf("user %s is not allowed to access interface %s: %w", user.Id, id, domain.ErrNoPermission)
}
return nil
}
// endregion helper-functions

View File

@@ -60,7 +60,6 @@ func (f *mockController) PingAddresses(_ context.Context, _ string) (*domain.Pin
type mockDB struct {
savedPeers map[domain.PeerIdentifier]*domain.Peer
iface *domain.Interface
interfaces []domain.Interface
}
func (f *mockDB) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
@@ -80,9 +79,6 @@ func (f *mockDB) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier
return nil, nil
}
func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
if f.interfaces != nil {
return f.interfaces, nil
}
if f.iface != nil {
return []domain.Interface{*f.iface}, nil
}

View File

@@ -214,10 +214,6 @@ type LdapProvider struct {
// If RegistrationEnabled is set to true, wg-portal will create new users that do not exist in the database.
RegistrationEnabled bool `yaml:"registration_enabled"`
// InterfaceFilter allows restricting interfaces using an LDAP filter.
// Map key is the interface identifier (e.g., "wg0"), value is the filter string.
InterfaceFilter map[string]string `yaml:"interface_filter"`
// If LogUserInfo is set to true, the user info retrieved from the LDAP provider will be logged in trace level.
LogUserInfo bool `yaml:"log_user_info"`
}

View File

@@ -78,33 +78,6 @@ type Interface struct {
PeerDefPostUp string // default action that is executed after the device is up
PeerDefPreDown string // default action that is executed before the device is down
PeerDefPostDown string // default action that is executed after the device is down
// Self-provisioning access control
LdapAllowedUsers map[string][]UserIdentifier `gorm:"serializer:json"` // Materialised during LDAP sync, keyed by ProviderName
}
// IsUserAllowed returns true if the interface has no filter, or if the user is in the allowed list.
func (i *Interface) IsUserAllowed(userId UserIdentifier, cfg *config.Config) bool {
isRestricted := false
for _, provider := range cfg.Auth.Ldap {
if _, exists := provider.InterfaceFilter[string(i.Identifier)]; exists {
isRestricted = true
break
}
}
if !isRestricted {
return true // The interface is completely unrestricted by LDAP config
}
for _, allowedUsers := range i.LdapAllowedUsers {
for _, uid := range allowedUsers {
if uid == userId {
return true
}
}
}
return false
}
// PublicInfo returns a copy of the interface with only the public information.

View File

@@ -68,7 +68,7 @@ type User struct {
WebAuthnCredentialList []UserWebauthnCredential `gorm:"foreignKey:user_identifier"` // the webauthn credentials of the user, used for webauthn authentication
// API token for REST API access
ApiToken string `form:"api_token" binding:"omitempty" gorm:"serializer:encstr"`
ApiToken string `form:"api_token" binding:"omitempty"`
ApiTokenCreated *time.Time
LinkedPeerCount int `gorm:"-"`