Compare commits

...

7 Commits

Author SHA1 Message Date
dependabot[bot]
29dda9fcb4 chore(deps): bump the actions group with 2 updates
Bumps the actions group with 2 updates: [nolar/setup-k3d-k3s](https://github.com/nolar/setup-k3d-k3s) and [docker/login-action](https://github.com/docker/login-action).


Updates `nolar/setup-k3d-k3s` from 1.0.10 to 1.1.0
- [Release notes](https://github.com/nolar/setup-k3d-k3s/releases)
- [Commits](8bf8d22160...62c9d1bd2b)

Updates `docker/login-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](b45d80f862...4907a6ddec)

---
updated-dependencies:
- dependency-name: nolar/setup-k3d-k3s
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 13:54:41 +00:00
Mykhailo Roit
72f9123592 Add test-in-docker target to Makefile (#659)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* Add test-in-docker target to Makefile

Add a target to run tests in Docker for non-Linux environments.

* Add GOVERSION variable to Makefile

* fix: update test-in-docker command to use user permissions

* Fix docker command syntax in Makefile
2026-04-03 22:01:07 +02:00
Mykhailo Roit
0e9e9d697f fix: "created_at" for users (#656)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* fix: created_at for users

* added tests for: created_at for users

* cleanup fixes

---------

Co-authored-by: Christoph Haas <christoph.h@sprinternet.at>
2026-04-01 11:58:22 +02:00
Christoph
87bfd5b23a feat: allow encrypting user api token using gorm serializer 2026-04-01 11:42:07 +02:00
h44z
920806b231 chore: update frontend deps (#657)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
2026-04-01 00:20:35 +02:00
Leandre Chamberland-Dozois
ec08e31eb7 feat(frontend): add confirmation dialog before deleting users, peers, and interfaces (#654)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
* feat(frontend): add confirmation dialog before deleting users, peers, and interfaces (#652)

Add a browser confirm() dialog to the delete functions in UserEditModal,
PeerEditModal, and InterfaceEditModal to prevent accidental deletions.
The bulk-delete actions in UserView already had this protection; this
change brings single-item deletion in line with that behavior.

Translation keys (confirm-delete) added for all 10 supported locales:
de, en, es, fr, ko, pt, ru, uk, vi, zh.

Signed-off-by: LeC-D <leo.openc@gmail.com>

* fix broken translation files

---------

Signed-off-by: LeC-D <leo.openc@gmail.com>
Co-authored-by: Christoph Haas <christoph.h@sprinternet.at>
2026-03-31 19:49:53 +02:00
Jacopo Clark
c1a7edcc9a fix: prevent interface address clearing during startup (#651)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
Signed-off-by: jc <37738506+theguy147@users.noreply.github.com>
Co-authored-by: jc <37738506+theguy147@users.noreply.github.com>
2026-03-25 22:08:06 +01:00
26 changed files with 2337 additions and 1899 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@62c9d1bd2bc843275c85d2e7dcd696edc1160eee # v1.1.0
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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -1,7 +1,8 @@
# Go parameters
GOCMD=go
GOVERSION=1.25
MODULENAME=github.com/h44z/wg-portal
GOFILES:=$(shell go list ./... | grep -v /vendor/)
GOFILES=$(shell go list ./... | grep -v /vendor/)
BUILDDIR=dist
BINARIES=$(subst cmd/,,$(wildcard cmd/*))
IMAGE=h44z/wg-portal
@@ -51,6 +52,11 @@ format:
.PHONY: test
test: test-vet test-race
#> test-in-docker: Run tests in Docker (for non-Linux environments e.g. MacOS)
.PHONY: test-in-docker
test-in-docker:
docker run --rm -u $(shell id -u):$(shell id -g) -e HOME=/tmp -v $(PWD):/app -w /app golang:$(GOVERSION) make test
#< test-vet: Static code analysis
.PHONY: test-vet
test-vet: build-dependencies

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"></script>
<script src="/api/v0/config/frontend.js" vite-ignore></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.1.0",
"@fortawesome/fontawesome-free": "^7.2.0",
"@kyvg/vue3-notification": "^3.4.2",
"@popperjs/core": "^2.11.8",
"@simplewebauthn/browser": "^13.2.2",
"@vojtechlanka/vue-tags-input": "^3.1.1",
"@simplewebauthn/browser": "^13.3.0",
"@vojtechlanka/vue-tags-input": "^3.1.2",
"bootstrap": "^5.3.8",
"bootswatch": "^5.3.8",
"cidr-tools": "^11.0.3",
"cidr-tools": "^11.3.2",
"flag-icons": "^7.5.0",
"ip-address": "^10.1.0",
"is-cidr": "^6.0.1",
"is-cidr": "^6.0.3",
"is-ip": "^5.0.1",
"pinia": "^3.0.4",
"prismjs": "^1.30.0",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"vue": "^3.5.31",
"vue-i18n": "^11.3.0",
"vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^4.6.3"
"vue-router": "^5.0.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.2",
"sass-embedded": "^1.93.3",
"vite": "^7.2.7"
"@vitejs/plugin-vue": "^6.0.5",
"sass-embedded": "^1.98.0",
"vite": "^8.0.3"
}
}

View File

@@ -315,6 +315,7 @@ 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;
opacity: 1.0;
}
.modal-backdrop {
background-color: rgba(0,0,0,0.6) !important;
}
.modal-backdrop.show {
opacity: 1 !important;
opacity: 1.0 !important;
}
</style>

View File

@@ -294,6 +294,7 @@ 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,6 +114,7 @@ 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,7 +382,8 @@
"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."
"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?"
},
"interface-view": {
"headline": "Konfiguration für Schnittstelle:"
@@ -503,7 +504,8 @@
"placeholder": "Persistentes Keepalive (0 = Standard)"
}
},
"button-apply-defaults": "Peer-Standardeinstellungen anwenden"
"button-apply-defaults": "Peer-Standardeinstellungen anwenden",
"confirm-delete": "Interface '{id}' wirklich löschen?"
},
"peer-view": {
"headline-peer": "Peer:",
@@ -625,7 +627,8 @@
},
"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,7 +382,8 @@
"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."
"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}'?"
},
"interface-view": {
"headline": "Config for Interface:"
@@ -503,8 +504,8 @@
"placeholder": "Persistent Keepalive (0 = default)"
}
},
"button-apply-defaults": "Apply Peer Defaults"
"button-apply-defaults": "Apply Peer Defaults",
"confirm-delete": "Are you sure you want to delete interface '{id}'?"
},
"peer-view": {
"headline-peer": "Peer:",
@@ -626,7 +627,8 @@
},
"expires-at": {
"label": "Expiry date"
}
},
"confirm-delete": "Are you sure you want to delete peer '{id}'?"
},
"peer-multi-create": {
"headline-peer": "Create multiple peers",

File diff suppressed because it is too large Load Diff

View File

@@ -126,9 +126,7 @@
"peer-expiring": "Le pair expire le",
"peer-connected": "Connecté",
"peer-not-connected": "Non connecté",
"peer-handshake": "Dernière négociation :",
"button-show-peer": "Afficher le pair",
"button-edit-peer": "Modifier le pair"
"peer-handshake": "Dernière négociation :"
},
"users": {
"headline": "Administration des utilisateurs",
@@ -264,7 +262,8 @@
},
"admin": {
"label": "Est Admin"
}
},
"confirm-delete": "Voulez-vous vraiment supprimer l'utilisateur \"{id}\" ?"
},
"interface-view": {
"headline": "Configuration pour l'interface :"
@@ -377,7 +376,8 @@
"placeholder": "Persistent Keepalive (0 = par défaut)"
}
},
"button-apply-defaults": "Appliquer les valeurs par défaut des pairs"
"button-apply-defaults": "Appliquer les valeurs par défaut des pairs",
"confirm-delete": "Voulez-vous vraiment supprimer l'interface \"{id}\" ?"
},
"peer-view": {
"headline-peer": "Pair :",
@@ -493,7 +493,8 @@
},
"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,6 +282,7 @@
"label": "관리자 여부"
}
},
"confirm-delete": "사용자 '{id}'를 삭제하시겠습니까?",
"interface-view": {
"headline": "인터페이스 구성:"
},
@@ -393,7 +394,8 @@
"placeholder": "영구 Keepalive (0 = 기본값)"
}
},
"button-apply-defaults": "피어 기본값 적용"
"button-apply-defaults": "피어 기본값 적용",
"confirm-delete": "인터페이스 '{id}'를 삭제하시겠습니까?"
},
"peer-view": {
"headline-peer": "피어:",
@@ -509,7 +511,8 @@
},
"expires-at": {
"label": "만료 날짜"
}
},
"confirm-delete": "피어 '{id}'를 삭제하시겠습니까?"
},
"peer-multi-create": {
"headline-peer": "여러 피어 생성",

View File

@@ -300,7 +300,8 @@
},
"admin": {
"label": "É Administrador"
}
},
"confirm-delete": "Tem certeza que deseja excluir o utilizador '{id}'?"
},
"interface-view": {
"headline": "Configuração para a Interface:"
@@ -413,7 +414,8 @@
"placeholder": "Keepalive persistente (0 = padrão)"
}
},
"button-apply-defaults": "Aplicar Padrões de Peer"
"button-apply-defaults": "Aplicar Padrões de Peer",
"confirm-delete": "Tem certeza que deseja excluir a interface '{id}'?"
},
"peer-view": {
"headline-peer": "Peer:",
@@ -530,7 +532,8 @@
},
"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,7 +366,8 @@
},
"admin": {
"label": "Является администратором"
}
},
"confirm-delete": "Вы уверены, что хотите удалить пользователя «{id}»?"
},
"interface-view": {
"headline": "Конфигурация интерфейса:"
@@ -484,7 +485,8 @@
"placeholder": "Постоянное поддержание активности (0 = значение по умолчанию)"
}
},
"button-apply-defaults": "Применить настройки пира по умолчанию"
"button-apply-defaults": "Применить настройки пира по умолчанию",
"confirm-delete": "Вы уверены, что хотите удалить интерфейс «{id}»?"
},
"peer-view": {
"headline-peer": "Пир:",
@@ -605,7 +607,8 @@
},
"expires-at": {
"label": "Дата истечения срока действия"
}
},
"confirm-delete": "Вы уверены, что хотите удалить пир «{id}»?"
},
"peer-multi-create": {
"headline-peer": "Создать несколько узлов",

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
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'
@@ -20,11 +19,6 @@ const router = createRouter({
name: 'login',
component: LoginView
},
{
path: '/interface',
name: 'interface',
component: InterfaceView
},
{
path: '/interfaces',
name: 'interfaces',

View File

@@ -232,21 +232,19 @@ 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{}))
existingSysStat := SysStat{}
var existingSysStat SysStat
var err error
r.db.Order("schema_version desc").First(&existingSysStat) // get latest version
// Migration: 0 --> 1
if existingSysStat.SchemaVersion == 0 {
const schemaVersion = 1
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, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return 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
@@ -262,14 +260,10 @@ func (r *SqlRepo) migrate() error {
}
slog.Debug("migrated interface create_default_peer flags", "schema_version", schemaVersion)
}
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: 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
}
// Migration: 2 --> 3
@@ -307,19 +301,45 @@ func (r *SqlRepo) migrate() error {
if err != nil {
return fmt.Errorf("failed to migrate to multi-auth: %w", err)
}
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: 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)
}
// 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
}
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.
@@ -482,7 +502,7 @@ func (r *SqlRepo) getOrCreateInterface(
Identifier: id,
}
err := tx.Attrs(interfaceDefaults).FirstOrCreate(&in, id).Error
err := tx.Preload("Addresses").Attrs(interfaceDefaults).FirstOrCreate(&in, id).Error
if err != nil {
return nil, err
}
@@ -691,7 +711,7 @@ func (r *SqlRepo) getOrCreatePeer(ui *domain.ContextUserInfo, tx *gorm.DB, id do
Identifier: id,
}
err := tx.Attrs(interfaceDefaults).FirstOrCreate(&peer, id).Error
err := tx.Preload("Addresses").Attrs(interfaceDefaults).FirstOrCreate(&peer, id).Error
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,168 @@
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

@@ -0,0 +1,73 @@
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

@@ -533,6 +533,7 @@ 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

@@ -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"`
ApiToken string `form:"api_token" binding:"omitempty" gorm:"serializer:encstr"`
ApiTokenCreated *time.Time
LinkedPeerCount int `gorm:"-"`