From 1394be2341562c234e29d0915b275668518ace7a Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Mon, 12 May 2025 22:53:43 +0200 Subject: [PATCH 01/69] add webauthn (passkey) support --- cmd/wg-portal/main.go | 6 +- docs/documentation/configuration/examples.md | 4 + docs/documentation/configuration/overview.md | 15 +- frontend/index.html | 2 +- frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/App.vue | 3 +- frontend/src/lang/translations/de.json | 32 +- frontend/src/lang/translations/en.json | 32 +- frontend/src/stores/auth.js | 186 ++++++++++- frontend/src/stores/profile.js | 4 +- frontend/src/views/LoginView.vue | 58 +++- frontend/src/views/SettingsView.vue | 106 +++++- go.mod | 7 + go.sum | 16 +- internal/adapters/database.go | 31 +- .../app/api/core/assets/doc/v0_swagger.json | 171 ++++++++++ .../app/api/core/assets/doc/v0_swagger.yaml | 112 +++++++ .../v0/handlers/endpoint_authentication.go | 291 +++++++++++++++++ .../app/api/v0/handlers/endpoint_config.go | 25 +- internal/app/api/v0/handlers/web_session.go | 2 + internal/app/api/v0/model/models.go | 1 + .../app/api/v0/model/models_authentication.go | 36 ++- internal/app/auth/webauthn.go | 301 ++++++++++++++++++ internal/app/users/user_manager.go | 21 ++ internal/config/auth.go | 8 + internal/config/config.go | 2 + internal/domain/user.go | 156 +++++++++ 28 files changed, 1603 insertions(+), 33 deletions(-) create mode 100644 internal/app/auth/webauthn.go diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go index 456290c..dbd2020 100644 --- a/cmd/wg-portal/main.go +++ b/cmd/wg-portal/main.go @@ -88,6 +88,9 @@ func main() { authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager) internal.AssertNoError(err) + webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager) + internal.AssertNoError(err) + wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database) internal.AssertNoError(err) wireGuardManager.StartBackgroundJobs(ctx) @@ -124,7 +127,8 @@ func main() { apiV0BackendInterfaces := backendV0.NewInterfaceService(cfg, wireGuardManager, cfgFileManager) apiV0BackendPeers := backendV0.NewPeerService(cfg, wireGuardManager, cfgFileManager, mailManager) - apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator) + apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator, + webAuthn) apiV0EndpointAudit := handlersV0.NewAuditEndpoint(cfg, apiV0Auth, auditManager) apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers) apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces) diff --git a/docs/documentation/configuration/examples.md b/docs/documentation/configuration/examples.md index 1409d2b..41a4034 100644 --- a/docs/documentation/configuration/examples.md +++ b/docs/documentation/configuration/examples.md @@ -32,6 +32,10 @@ database: type: sqlite dsn: data/sqlite.db encryption_passphrase: change-this-s3cr3t-encryption-passphrase + +auth: + webauthn: + enabled: true ``` ## LDAP Authentication and Synchronization diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index bc47ba0..3cbdb59 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -72,6 +72,8 @@ auth: oidc: [] oauth: [] ldap: [] + webauthn: + enabled: true web: listening_address: :8888 @@ -120,6 +122,7 @@ More advanced options are found in the subsequent `Advanced` section. ### `admin_password` - **Default:** `wgportal` - **Description:** The administrator password. The default password of `wgportal` should be changed immediately. +- **Important:** The password should be strong and secure. It is recommended to use a password with at least 16 characters, including uppercase and lowercase letters, numbers, and special characters. ### `admin_api_token` - **Default:** *(empty)* @@ -334,7 +337,7 @@ Options for configuring email notifications or sending peer configurations via e ## Auth -WireGuard Portal supports multiple authentication strategies, including **OpenID Connect** (`oidc`), **OAuth** (`oauth`), and **LDAP** (`ldap`). +WireGuard Portal supports multiple authentication strategies, including **OpenID Connect** (`oidc`), **OAuth** (`oauth`), **Passkeys** (`webauthn`) and **LDAP** (`ldap`). Each can have multiple providers configured. Below are the relevant keys. --- @@ -580,6 +583,16 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`: --- +### WebAuthn (Passkeys) + +The `webauthn` section contains configuration options for WebAuthn authentication (passkeys). + +#### `enabled` +- **Default:** `true` +- **Description:** If `true`, Passkey authentication is enabled. If `false`, WebAuthn is disabled. + Users are encouraged to use Passkeys for secure authentication instead of passwords. + If a passkey is registered, the password login is still available as a fallback. Ensure that the password is strong and secure. + ## Web The web section contains configuration options for the web server, including the listening address, session management, and CSRF protection. diff --git a/frontend/index.html b/frontend/index.html index b667f8d..fd8033f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -24,7 +24,7 @@
-
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f12f2f7..afbb37b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@fortawesome/fontawesome-free": "^6.7.2", "@kyvg/vue3-notification": "^3.4.1", "@popperjs/core": "^2.11.8", + "@simplewebauthn/browser": "^13.1.0", "@vojtechlanka/vue-tags-input": "^3.1.1", "bootstrap": "^5.3.5", "bootswatch": "^5.3.5", @@ -863,6 +864,12 @@ "win32" ] }, + "node_modules/@simplewebauthn/browser": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.1.0.tgz", + "integrity": "sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index baf850f..0be893a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@fortawesome/fontawesome-free": "^6.7.2", "@kyvg/vue3-notification": "^3.4.1", "@popperjs/core": "^2.11.8", + "@simplewebauthn/browser": "^13.1.0", "@vojtechlanka/vue-tags-input": "^3.1.1", "bootstrap": "^5.3.5", "bootswatch": "^5.3.5", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 53a4b7c..cffbb8a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -140,6 +140,7 @@ const currentYear = ref(new Date().getFullYear()) - + + diff --git a/frontend/src/lang/translations/de.json b/frontend/src/lang/translations/de.json index 32a4251..3a172d6 100644 --- a/frontend/src/lang/translations/de.json +++ b/frontend/src/lang/translations/de.json @@ -29,7 +29,8 @@ "label": "Passwort", "placeholder": "Bitte geben Sie Ihr Passwort ein" }, - "button": "Anmelden" + "button": "Anmelden", + "button-webauthn": "Passkey verwenden" }, "menu": { "home": "Home", @@ -188,6 +189,35 @@ "button-enable-title": "Aktivieren Sie die API, dadurch wird ein neuer Token generiert.", "button-enable-text": "API aktivieren", "api-link": "API Dokumentation" + }, + "webauthn": { + "headline": "Passkey-Einstellungen", + "abstract": "Passkeys sind eine moderne Möglichkeit, Benutzer ohne Passwort zu authentifizieren. Sie werden sicher in Ihrem Browser gespeichert und können verwendet werden, um sich im WireGuard-Portal anzumelden.", + "active-description": "Mindestens ein Passkey ist derzeit für Ihr Benutzerkonto aktiv.", + "inactive-description": "Für Ihr Benutzerkonto sind derzeit keine Passkeys registriert. Drücken Sie die Schaltfläche unten, um einen neuen Passkey zu registrieren.", + "table": { + "name": "Name", + "created": "Erstellt", + "actions": "" + }, + "credentials-list": "Derzeit registrierte Passkeys", + "modal-delete": { + "headline": "Passkey löschen", + "abstract": "Sind Sie sicher, dass Sie diesen Passkey löschen möchten? Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.", + "created": "Erstellt:", + "button-delete": "Löschen", + "button-cancel": "Abbrechen" + }, + "button-rename-title": "Umbenennen", + "button-rename-text": "Passkey umbenennen.", + "button-save-title": "Speichern", + "button-save-text": "Neuen Namen des Passkeys speichern.", + "button-cancel-title": "Abbrechen", + "button-cancel-text": "Umbenennung des Passkeys abbrechen.", + "button-delete-title": "Löschen", + "button-delete-text": "Passkey löschen. Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.", + "button-register-title": "Passkey registrieren", + "button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern." } }, "audit": { diff --git a/frontend/src/lang/translations/en.json b/frontend/src/lang/translations/en.json index 49ef9a8..4ee8199 100644 --- a/frontend/src/lang/translations/en.json +++ b/frontend/src/lang/translations/en.json @@ -29,7 +29,8 @@ "label": "Password", "placeholder": "Please enter your password" }, - "button": "Sign in" + "button": "Sign in", + "button-webauthn": "Use Passkey" }, "menu": { "home": "Home", @@ -188,6 +189,35 @@ "button-enable-title": "Enable API, this will generate a new token.", "button-enable-text": "Enable API", "api-link": "API Documentation" + }, + "webauthn": { + "headline": "Passkey Settings", + "abstract": "Passkeys are a modern way to authenticate users without the need for passwords. They are stored securely in your browser and can be used to log in to the WireGuard Portal.", + "active-description": "At least one passkey is currently active for your user account.", + "inactive-description": "No passkeys are currently registered for your user account. Press the button below to register a new passkey.", + "table": { + "name": "Name", + "created": "Created", + "actions": "" + }, + "credentials-list": "Currently registered Passkeys", + "modal-delete": { + "headline": "Delete Passkey", + "abstract": "Are you sure you want to delete this passkey? You will not be able to log in with this passkey anymore.", + "created": "Created:", + "button-delete": "Delete", + "button-cancel": "Cancel" + }, + "button-rename-title": "Rename", + "button-rename-text": "Rename the passkey.", + "button-save-title": "Save", + "button-save-text": "Save the new name of the passkey.", + "button-cancel-title": "Cancel", + "button-cancel-text": "Cancel the renaming of the passkey.", + "button-delete-title": "Delete", + "button-delete-text": "Delete the passkey. You will not be able to log in with this passkey anymore.", + "button-register-title": "Register Passkey", + "button-register-text": "Register a new Passkey to secure your account." } }, "audit": { diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index b7619b2..50c0d09 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -3,13 +3,17 @@ import { defineStore } from 'pinia' import { notify } from "@kyvg/vue3-notification"; import { apiWrapper } from '@/helpers/fetch-wrapper' import router from '../router' +import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser'; +import {base64_url_encode} from "@/helpers/encoding"; export const authStore = defineStore('auth',{ state: () => ({ // initialize state from local storage to enable user to stay logged in user: JSON.parse(localStorage.getItem('user')), providers: [], - returnUrl: localStorage.getItem('returnUrl') + returnUrl: localStorage.getItem('returnUrl'), + webAuthnCredentials: [], + fetching: false, }), getters: { UserIdentifier: (state) => state.user?.Identifier || 'unknown', @@ -18,6 +22,14 @@ export const authStore = defineStore('auth',{ IsAuthenticated: (state) => state.user != null, IsAdmin: (state) => state.user?.IsAdmin || false, ReturnUrl: (state) => state.returnUrl || '/', + IsWebAuthnEnabled: (state) => { + if (state.webAuthnCredentials) { + return state.webAuthnCredentials.length > 0 + } + return false + }, + WebAuthnCredentials: (state) => state.webAuthnCredentials || [], + isFetching: (state) => state.fetching, }, actions: { SetReturnUrl(link) { @@ -60,6 +72,23 @@ export const authStore = defineStore('auth',{ return Promise.reject(err) }) }, + // LoadWebAuthnCredentials returns promise that might have been rejected if the session was not authenticated. + async LoadWebAuthnCredentials() { + this.fetching = true + return apiWrapper.get(`/auth/webauthn/credentials`) + .then(credentials => { + this.setWebAuthnCredentials(credentials) + }) + .catch(error => { + this.setWebAuthnCredentials([]) + console.log("Failed to load webauthn credentials:", error) + notify({ + title: "Backend Connection Failure", + text: error, + type: 'error', + }) + }) + }, // Login returns promise that might have been rejected if the login attempt was not successful. async Login(username, password) { return apiWrapper.post(`/auth/login`, { username, password }) @@ -93,6 +122,157 @@ export const authStore = defineStore('auth',{ await router.push('/login') }, + async RegisterWebAuthn() { + // check if the browser supports WebAuthn + if (!browserSupportsWebAuthn()) { + console.error("WebAuthn is not supported by this browser."); + notify({ + title: "WebAuthn not supported", + text: "This browser does not support WebAuthn.", + type: 'error' + }); + return Promise.reject(new Error("WebAuthn not supported")); + } + + this.fetching = true + console.log("Starting WebAuthn registration...") + await apiWrapper.post(`/auth/webauthn/register/start`, {}) + .then(optionsJSON => { + notify({ + title: "Passkey registration", + text: "Starting passkey registration, follow the instructions in the browser." + }); + console.log("Started WebAuthn registration with options: ", optionsJSON) + + return startRegistration({ optionsJSON: optionsJSON.publicKey }).then(attResp => { + console.log("Finishing WebAuthn registration...") + return apiWrapper.post(`/auth/webauthn/register/finish`, attResp) + .then(credentials => { + console.log("Passkey registration finished successfully: ", credentials) + this.setWebAuthnCredentials(credentials) + notify({ + title: "Passkey registration", + text: "A new passkey has been registered successfully!", + type: 'success' + }); + }) + .catch(err => { + this.fetching = false + console.error("Failed to register passkey:", err); + notify({ + title: "Passkey registration failed", + text: err, + type: 'error' + }); + }) + }).catch(err => { + this.fetching = false + console.error("Failed to start WebAuthn registration:", err); + notify({ + title: "Failed to start Passkey registration", + text: err, + type: 'error' + }); + }) + }) + .catch(err => { + this.fetching = false + console.error("Failed to start WebAuthn registration:", err); + notify({ + title: "Failed to start WebAuthn registration", + text: err, + type: 'error' + }); + }) + }, + async DeleteWebAuthnCredential(credentialId) { + this.fetching = true + return apiWrapper.delete(`/auth/webauthn/credential/${base64_url_encode(credentialId)}`) + .then(credentials => { + this.setWebAuthnCredentials(credentials) + notify({ + title: "Success", + text: "Passkey deleted successfully!", + type: 'success', + }) + }) + .catch(err => { + this.fetching = false + console.error("Failed to delete webauthn credential:", err); + notify({ + title: "Backend Connection Failure", + text: err, + type: 'error', + }) + }) + }, + async RenameWebAuthnCredential(credential) { + this.fetching = true + return apiWrapper.put(`/auth/webauthn/credential/${base64_url_encode(credential.ID)}`, { + Name: credential.Name, + }) + .then(credentials => { + this.setWebAuthnCredentials(credentials) + notify({ + title: "Success", + text: "Passkey renamed successfully!", + type: 'success', + }) + }) + .catch(err => { + this.fetching = false + console.error("Failed to rename webauthn credential", credential.ID, ":", err); + notify({ + title: "Backend Connection Failure", + text: err, + type: 'error', + }) + }) + }, + async LoginWebAuthn() { + // check if the browser supports WebAuthn + if (!browserSupportsWebAuthn()) { + console.error("WebAuthn is not supported by this browser."); + notify({ + title: "WebAuthn not supported", + text: "This browser does not support WebAuthn.", + type: 'error' + }); + return Promise.reject(new Error("WebAuthn not supported")); + } + + this.fetching = true + console.log("Starting WebAuthn login...") + await apiWrapper.post(`/auth/webauthn/login/start`, {}) + .then(optionsJSON => { + console.log("Started WebAuthn login with options: ", optionsJSON) + + return startAuthentication({ optionsJSON: optionsJSON.publicKey }).then(asseResp => { + console.log("Finishing WebAuthn login ...") + return apiWrapper.post(`/auth/webauthn/login/finish`, asseResp) + .then(user => { + console.log("Passkey login finished successfully for user:", user.Identifier) + this.ResetReturnUrl() + this.setUserInfo(user) + return user.Identifier + }) + .catch(err => { + console.error("Failed to login with passkey:", err) + this.setUserInfo(null) + return Promise.reject(new Error("login failed")) + }) + }).catch(err => { + console.error("Failed to finish passkey login:", err) + this.setUserInfo(null) + return Promise.reject(new Error("login failed")) + }) + }) + .catch(err => { + console.error("Failed to start passkey login:", err) + this.setUserInfo(null) + return Promise.reject(new Error("login failed")) + }) + }, // -- internal setters setUserInfo(userInfo) { // store user details and jwt in local storage to keep user logged in between page refreshes @@ -120,5 +300,9 @@ export const authStore = defineStore('auth',{ localStorage.removeItem('user') } }, + setWebAuthnCredentials(credentials) { + this.fetching = false + this.webAuthnCredentials = credentials + } } }); diff --git a/frontend/src/stores/profile.js b/frontend/src/stores/profile.js index 243622a..ba3798c 100644 --- a/frontend/src/stores/profile.js +++ b/frontend/src/stores/profile.js @@ -129,7 +129,7 @@ export const profileStore = defineStore('profile', { return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/api/enable`) .then(this.setUser) .catch(error => { - this.setPeers([]) + this.fetching = false console.log("Failed to activate API for ", currentUser, ": ", error) notify({ title: "Backend Connection Failure", @@ -143,7 +143,7 @@ export const profileStore = defineStore('profile', { return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/api/disable`) .then(this.setUser) .catch(error => { - this.setPeers([]) + this.fetching = false console.log("Failed to deactivate API for ", currentUser, ": ", error) notify({ title: "Backend Connection Failure", diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 1523c27..d5037e2 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -1,6 +1,6 @@ diff --git a/go.mod b/go.mod index 9775d6b..62bdd8c 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-pkgz/routegroup v1.4.1 github.com/go-playground/validator/v10 v10.26.0 + github.com/go-webauthn/webauthn v0.12.3 github.com/google/uuid v1.6.0 github.com/prometheus-community/pro-bing v0.7.0 github.com/prometheus/client_golang v1.22.0 @@ -39,6 +40,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fxamacker/cbor/v2 v2.8.0 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect @@ -51,9 +53,12 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.9.2 // indirect github.com/go-test/deep v1.1.1 // indirect + github.com/go-webauthn/x v0.1.20 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-tpm v0.9.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.4 // indirect @@ -69,6 +74,7 @@ require ( github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/microsoft/go-mssqldb v1.8.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -78,6 +84,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect github.com/vishvananda/netns v0.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/net v0.39.0 // indirect diff --git a/go.sum b/go.sum index ddfdb51..b5e7af0 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/ github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= +github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= @@ -79,10 +81,14 @@ github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRj github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE= +github.com/go-webauthn/webauthn v0.12.3/go.mod h1:4JRe8Z3W7HIw8NGEWn2fnUwecoDzkkeach/NnvhkqGY= +github.com/go-webauthn/x v0.1.20 h1:brEBDqfiPtNNCdS/peu8gARtq8fIPsHz0VzpPjGvgiw= +github.com/go-webauthn/x v0.1.20/go.mod h1:n/gAc8ssZJGATM0qThE+W+vfgXiMedsWi3wf/C4lld0= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= @@ -90,6 +96,8 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc= +github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -154,6 +162,8 @@ github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3ao github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -203,6 +213,8 @@ github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA= github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98= github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk= diff --git a/internal/adapters/database.go b/internal/adapters/database.go index 0ae20ad..14726ae 100644 --- a/internal/adapters/database.go +++ b/internal/adapters/database.go @@ -220,6 +220,8 @@ func (r *SqlRepo) preCheck() error { func (r *SqlRepo) migrate() error { slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{})) slog.Debug("running migration: user", "result", r.db.AutoMigrate(&domain.User{})) + slog.Debug("running migration: user webauthn credentials", "result", + r.db.AutoMigrate(&domain.UserWebauthnCredential{})) slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{})) slog.Debug("running migration: peer", "result", r.db.AutoMigrate(&domain.Peer{})) slog.Debug("running migration: peer status", "result", r.db.AutoMigrate(&domain.PeerStatus{})) @@ -746,7 +748,7 @@ func (r *SqlRepo) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) { var user domain.User - err := r.db.WithContext(ctx).First(&user, id).Error + err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").First(&user, id).Error if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { return nil, domain.ErrNotFound @@ -764,7 +766,7 @@ func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domai func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { var users []domain.User - err := r.db.WithContext(ctx).Where("email = ?", email).Find(&users).Error + err := r.db.WithContext(ctx).Where("email = ?", email).Preload("WebAuthnCredentialList").Find(&users).Error if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { return nil, domain.ErrNotFound } @@ -785,11 +787,26 @@ func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.Use return &user, nil } +// GetUserByWebAuthnCredential returns the user with the given webauthn credential id. +func (r *SqlRepo) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) { + var credential domain.UserWebauthnCredential + + err := r.db.WithContext(ctx).Where("credential_identifier = ?", credentialIdBase64).First(&credential).Error + if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { + return nil, domain.ErrNotFound + } + if err != nil { + return nil, err + } + + return r.GetUser(ctx, domain.UserIdentifier(credential.UserIdentifier)) +} + // GetAllUsers returns all users. func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) { var users []domain.User - err := r.db.WithContext(ctx).Find(&users).Error + err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Find(&users).Error if err != nil { return nil, err } @@ -808,6 +825,7 @@ func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User, Or("firstname LIKE ?", searchValue). Or("lastname LIKE ?", searchValue). Or("email LIKE ?", searchValue). + Preload("WebAuthnCredentialList"). Find(&users).Error if err != nil { return nil, err @@ -853,7 +871,7 @@ func (r *SqlRepo) SaveUser( // DeleteUser deletes the user with the given id. func (r *SqlRepo) DeleteUser(ctx context.Context, id domain.UserIdentifier) error { - err := r.db.WithContext(ctx).Delete(&domain.User{}, id).Error + err := r.db.WithContext(ctx).Unscoped().Select(clause.Associations).Delete(&domain.User{Identifier: id}).Error if err != nil { return err } @@ -897,6 +915,11 @@ func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *doma return err } + err = tx.Session(&gorm.Session{FullSaveAssociations: true}).Unscoped().Model(user).Association("WebAuthnCredentialList").Unscoped().Replace(user.WebAuthnCredentialList) + if err != nil { + return fmt.Errorf("failed to update users webauthn credentials: %w", err) + } + return nil } diff --git a/internal/app/api/core/assets/doc/v0_swagger.json b/internal/app/api/core/assets/doc/v0_swagger.json index 3ca06a1..0658046 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.json +++ b/internal/app/api/core/assets/doc/v0_swagger.json @@ -129,6 +129,152 @@ } } }, + "/auth/webauthn/credential/{id}": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "Update a WebAuthn credential.", + "operationId": "auth_handleWebAuthnCredentialsPut", + "parameters": [ + { + "type": "string", + "description": "Base64 encoded Credential ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Credential name", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.WebAuthnCredentialRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.WebAuthnCredentialResponse" + } + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "Delete a WebAuthn credential.", + "operationId": "auth_handleWebAuthnCredentialsDelete", + "parameters": [ + { + "type": "string", + "description": "Base64 encoded Credential ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.WebAuthnCredentialResponse" + } + } + } + } + } + }, + "/auth/webauthn/credentials": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "Get all available external login providers.", + "operationId": "auth_handleWebAuthnCredentialsGet", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.WebAuthnCredentialResponse" + } + } + } + } + } + }, + "/auth/webauthn/login/finish": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "Finish the WebAuthn login process.", + "operationId": "auth_handleWebAuthnLoginFinish", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.User" + } + } + } + } + }, + "/auth/webauthn/register/finish": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "Finish the WebAuthn registration process.", + "operationId": "auth_handleWebAuthnRegisterFinish", + "parameters": [ + { + "type": "string", + "default": "\"\"", + "description": "Credential name", + "name": "credential_name", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.WebAuthnCredentialResponse" + } + } + } + } + } + }, "/auth/{provider}/callback": { "get": { "produces": [ @@ -2093,6 +2239,9 @@ }, "SelfProvisioning": { "type": "boolean" + }, + "WebAuthnEnabled": { + "type": "boolean" } } }, @@ -2161,6 +2310,28 @@ "type": "string" } } + }, + "model.WebAuthnCredentialRequest": { + "type": "object", + "properties": { + "Name": { + "type": "string" + } + } + }, + "model.WebAuthnCredentialResponse": { + "type": "object", + "properties": { + "CreatedAt": { + "type": "string" + }, + "ID": { + "type": "string" + }, + "Name": { + "type": "string" + } + } } } } \ No newline at end of file diff --git a/internal/app/api/core/assets/doc/v0_swagger.yaml b/internal/app/api/core/assets/doc/v0_swagger.yaml index ab42b60..e8778b2 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.yaml +++ b/internal/app/api/core/assets/doc/v0_swagger.yaml @@ -387,6 +387,8 @@ definitions: type: boolean SelfProvisioning: type: boolean + WebAuthnEnabled: + type: boolean type: object model.User: properties: @@ -433,6 +435,20 @@ definitions: Source: type: string type: object + model.WebAuthnCredentialRequest: + properties: + Name: + type: string + type: object + model.WebAuthnCredentialResponse: + properties: + CreatedAt: + type: string + ID: + type: string + Name: + type: string + type: object info: contact: name: WireGuard Portal Developers @@ -548,6 +564,102 @@ paths: summary: Get information about the currently logged-in user. tags: - Authentication + /auth/webauthn/credential/{id}: + delete: + operationId: auth_handleWebAuthnCredentialsDelete + parameters: + - description: Base64 encoded Credential ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.WebAuthnCredentialResponse' + type: array + summary: Delete a WebAuthn credential. + tags: + - Authentication + put: + operationId: auth_handleWebAuthnCredentialsPut + parameters: + - description: Base64 encoded Credential ID + in: path + name: id + required: true + type: string + - description: Credential name + in: body + name: request + required: true + schema: + $ref: '#/definitions/model.WebAuthnCredentialRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.WebAuthnCredentialResponse' + type: array + summary: Update a WebAuthn credential. + tags: + - Authentication + /auth/webauthn/credentials: + get: + operationId: auth_handleWebAuthnCredentialsGet + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.WebAuthnCredentialResponse' + type: array + summary: Get all available external login providers. + tags: + - Authentication + /auth/webauthn/login/finish: + post: + operationId: auth_handleWebAuthnLoginFinish + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.User' + summary: Finish the WebAuthn login process. + tags: + - Authentication + /auth/webauthn/register/finish: + post: + operationId: auth_handleWebAuthnRegisterFinish + parameters: + - default: '""' + description: Credential name + in: query + name: credential_name + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.WebAuthnCredentialResponse' + type: array + summary: Finish the WebAuthn registration process. + tags: + - Authentication /config/frontend.js: get: operationId: config_handleConfigJsGet diff --git a/internal/app/api/v0/handlers/endpoint_authentication.go b/internal/app/api/v0/handlers/endpoint_authentication.go index 56a889b..de412e3 100644 --- a/internal/app/api/v0/handlers/endpoint_authentication.go +++ b/internal/app/api/v0/handlers/endpoint_authentication.go @@ -28,12 +28,54 @@ type AuthenticationService interface { OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error) } +type WebAuthnService interface { + Enabled() bool + StartWebAuthnRegistration(ctx context.Context, userId domain.UserIdentifier) ( + responseOptions []byte, + sessionData []byte, + err error, + ) + FinishWebAuthnRegistration( + ctx context.Context, + userId domain.UserIdentifier, + name string, + sessionDataAsJSON []byte, + r *http.Request, + ) ([]domain.UserWebauthnCredential, error) + GetCredentials( + ctx context.Context, + userId domain.UserIdentifier, + ) ([]domain.UserWebauthnCredential, error) + RemoveCredential( + ctx context.Context, + userId domain.UserIdentifier, + credentialIdBase64 string, + ) ([]domain.UserWebauthnCredential, error) + UpdateCredential( + ctx context.Context, + userId domain.UserIdentifier, + credentialIdBase64 string, + name string, + ) ([]domain.UserWebauthnCredential, error) + StartWebAuthnLogin(_ context.Context) ( + optionsAsJSON []byte, + sessionDataAsJSON []byte, + err error, + ) + FinishWebAuthnLogin( + ctx context.Context, + sessionDataAsJSON []byte, + r *http.Request, + ) (*domain.User, error) +} + type AuthEndpoint struct { cfg *config.Config authService AuthenticationService authenticator Authenticator session Session validate Validator + webAuthn WebAuthnService } func NewAuthEndpoint( @@ -42,6 +84,7 @@ func NewAuthEndpoint( session Session, validator Validator, authService AuthenticationService, + webAuthn WebAuthnService, ) AuthEndpoint { return AuthEndpoint{ cfg: cfg, @@ -49,6 +92,7 @@ func NewAuthEndpoint( authenticator: authenticator, session: session, validate: validator, + webAuthn: webAuthn, } } @@ -65,6 +109,19 @@ func (e AuthEndpoint) RegisterRoutes(g *routegroup.Bundle) { apiGroup.HandleFunc("GET /login/{provider}/init", e.handleOauthInitiateGet()) apiGroup.HandleFunc("GET /login/{provider}/callback", e.handleOauthCallbackGet()) + apiGroup.HandleFunc("POST /webauthn/login/start", e.handleWebAuthnLoginStart()) + apiGroup.HandleFunc("POST /webauthn/login/finish", e.handleWebAuthnLoginFinish()) + apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("GET /webauthn/credentials", + e.handleWebAuthnCredentialsGet()) + apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /webauthn/register/start", + e.handleWebAuthnRegisterStart()) + apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /webauthn/register/finish", + e.handleWebAuthnRegisterFinish()) + apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("DELETE /webauthn/credential/{id}", + e.handleWebAuthnCredentialsDelete()) + apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("PUT /webauthn/credential/{id}", + e.handleWebAuthnCredentialsPut()) + apiGroup.HandleFunc("POST /login", e.handleLoginPost()) apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /logout", e.handleLogoutPost()) } @@ -389,3 +446,237 @@ func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool { return true } + +// handleWebAuthnCredentialsGet returns a gorm Handler function. +// +// @ID auth_handleWebAuthnCredentialsGet +// @Tags Authentication +// @Summary Get all available external login providers. +// @Produce json +// @Success 200 {object} []model.WebAuthnCredentialResponse +// @Router /auth/webauthn/credentials [get] +func (e AuthEndpoint) handleWebAuthnCredentialsGet() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !e.webAuthn.Enabled() { + respond.JSON(w, http.StatusOK, []model.WebAuthnCredentialResponse{}) + return + } + + currentSession := e.session.GetData(r.Context()) + + userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier) + + credentials, err := e.webAuthn.GetCredentials(r.Context(), userIdentifier) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials)) + } +} + +// handleWebAuthnCredentialsDelete returns a gorm Handler function. +// +// @ID auth_handleWebAuthnCredentialsDelete +// @Tags Authentication +// @Summary Delete a WebAuthn credential. +// @Param id path string true "Base64 encoded Credential ID" +// @Produce json +// @Success 200 {object} []model.WebAuthnCredentialResponse +// @Router /auth/webauthn/credential/{id} [delete] +func (e AuthEndpoint) handleWebAuthnCredentialsDelete() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !e.webAuthn.Enabled() { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"}) + return + } + + currentSession := e.session.GetData(r.Context()) + + userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier) + + credentialId := Base64UrlDecode(request.Path(r, "id")) + + credentials, err := e.webAuthn.RemoveCredential(r.Context(), userIdentifier, credentialId) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials)) + } +} + +// handleWebAuthnCredentialsPut returns a gorm Handler function. +// +// @ID auth_handleWebAuthnCredentialsPut +// @Tags Authentication +// @Summary Update a WebAuthn credential. +// @Param id path string true "Base64 encoded Credential ID" +// @Param request body model.WebAuthnCredentialRequest true "Credential name" +// @Produce json +// @Success 200 {object} []model.WebAuthnCredentialResponse +// @Router /auth/webauthn/credential/{id} [put] +func (e AuthEndpoint) handleWebAuthnCredentialsPut() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !e.webAuthn.Enabled() { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"}) + return + } + + currentSession := e.session.GetData(r.Context()) + + userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier) + + credentialId := Base64UrlDecode(request.Path(r, "id")) + var req model.WebAuthnCredentialRequest + if err := request.BodyJson(r, &req); err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + credentials, err := e.webAuthn.UpdateCredential(r.Context(), userIdentifier, credentialId, req.Name) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials)) + } +} + +func (e AuthEndpoint) handleWebAuthnRegisterStart() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !e.webAuthn.Enabled() { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"}) + return + } + + currentSession := e.session.GetData(r.Context()) + + userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier) + + options, sessionData, err := e.webAuthn.StartWebAuthnRegistration(r.Context(), userIdentifier) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + currentSession.WebAuthnData = string(sessionData) + e.session.SetData(r.Context(), currentSession) + + respond.Data(w, http.StatusOK, "application/json", options) + } +} + +// handleWebAuthnRegisterFinish returns a gorm Handler function. +// +// @ID auth_handleWebAuthnRegisterFinish +// @Tags Authentication +// @Summary Finish the WebAuthn registration process. +// @Param credential_name query string false "Credential name" default("") +// @Produce json +// @Success 200 {object} []model.WebAuthnCredentialResponse +// @Router /auth/webauthn/register/finish [post] +func (e AuthEndpoint) handleWebAuthnRegisterFinish() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !e.webAuthn.Enabled() { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"}) + return + } + + name := request.QueryDefault(r, "credential_name", "") + + currentSession := e.session.GetData(r.Context()) + + webAuthnSessionData := []byte(currentSession.WebAuthnData) + currentSession.WebAuthnData = "" // clear the session data + e.session.SetData(r.Context(), currentSession) + + credentials, err := e.webAuthn.FinishWebAuthnRegistration( + r.Context(), + domain.UserIdentifier(currentSession.UserIdentifier), + name, + webAuthnSessionData, + r) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials)) + } +} + +func (e AuthEndpoint) handleWebAuthnLoginStart() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !e.webAuthn.Enabled() { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"}) + return + } + + currentSession := e.session.GetData(r.Context()) + + options, sessionData, err := e.webAuthn.StartWebAuthnLogin(r.Context()) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + currentSession.WebAuthnData = string(sessionData) + e.session.SetData(r.Context(), currentSession) + + respond.Data(w, http.StatusOK, "application/json", options) + } +} + +// handleWebAuthnLoginFinish returns a gorm Handler function. +// +// @ID auth_handleWebAuthnLoginFinish +// @Tags Authentication +// @Summary Finish the WebAuthn login process. +// @Produce json +// @Success 200 {object} model.User +// @Router /auth/webauthn/login/finish [post] +func (e AuthEndpoint) handleWebAuthnLoginFinish() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !e.webAuthn.Enabled() { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"}) + return + } + + currentSession := e.session.GetData(r.Context()) + + webAuthnSessionData := []byte(currentSession.WebAuthnData) + currentSession.WebAuthnData = "" // clear the session data + e.session.SetData(r.Context(), currentSession) + + user, err := e.webAuthn.FinishWebAuthnLogin( + r.Context(), + webAuthnSessionData, + r) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + e.setAuthenticatedUser(r, user) + + respond.JSON(w, http.StatusOK, model.NewUser(user, false)) + } +} diff --git a/internal/app/api/v0/handlers/endpoint_config.go b/internal/app/api/v0/handlers/endpoint_config.go index c81c2c9..d791f88 100644 --- a/internal/app/api/v0/handlers/endpoint_config.go +++ b/internal/app/api/v0/handlers/endpoint_config.go @@ -15,6 +15,7 @@ import ( "github.com/h44z/wg-portal/internal/app/api/core/respond" "github.com/h44z/wg-portal/internal/app/api/v0/model" "github.com/h44z/wg-portal/internal/config" + "github.com/h44z/wg-portal/internal/domain" ) //go:embed frontend_config.js.gotpl @@ -46,7 +47,7 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) { apiGroup := g.Mount("/config") apiGroup.HandleFunc("GET /frontend.js", e.handleConfigJsGet()) - apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("GET /settings", e.handleSettingsGet()) + apiGroup.HandleFunc("GET /settings", e.handleSettingsGet()) } // handleConfigJsGet returns a gorm Handler function. @@ -93,11 +94,21 @@ func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc { // @Router /config/settings [get] func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - respond.JSON(w, http.StatusOK, model.Settings{ - MailLinkOnly: e.cfg.Mail.LinkOnly, - PersistentConfigSupported: e.cfg.Advanced.ConfigStoragePath != "", - SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed, - ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly, - }) + sessionUser := domain.GetUserInfo(r.Context()) + + // For anonymous users, we return the settings object with minimal information + if sessionUser.Id == domain.CtxUnknownUserId || sessionUser.Id == "" { + respond.JSON(w, http.StatusOK, model.Settings{ + WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, + }) + } else { + respond.JSON(w, http.StatusOK, model.Settings{ + MailLinkOnly: e.cfg.Mail.LinkOnly, + PersistentConfigSupported: e.cfg.Advanced.ConfigStoragePath != "", + SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed, + ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly, + WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, + }) + } } } diff --git a/internal/app/api/v0/handlers/web_session.go b/internal/app/api/v0/handlers/web_session.go index dd4ca6c..1e12eaa 100644 --- a/internal/app/api/v0/handlers/web_session.go +++ b/internal/app/api/v0/handlers/web_session.go @@ -31,6 +31,8 @@ type SessionData struct { OauthProvider string OauthReturnTo string + WebAuthnData string + CsrfToken string } diff --git a/internal/app/api/v0/model/models.go b/internal/app/api/v0/model/models.go index e2298bc..a6bbc75 100644 --- a/internal/app/api/v0/model/models.go +++ b/internal/app/api/v0/model/models.go @@ -10,4 +10,5 @@ type Settings struct { PersistentConfigSupported bool `json:"PersistentConfigSupported"` SelfProvisioning bool `json:"SelfProvisioning"` ApiAdminOnly bool `json:"ApiAdminOnly"` + WebAuthnEnabled bool `json:"WebAuthnEnabled"` } diff --git a/internal/app/api/v0/model/models_authentication.go b/internal/app/api/v0/model/models_authentication.go index 1966405..b7283b1 100644 --- a/internal/app/api/v0/model/models_authentication.go +++ b/internal/app/api/v0/model/models_authentication.go @@ -1,6 +1,11 @@ package model -import "github.com/h44z/wg-portal/internal/domain" +import ( + "slices" + "strings" + + "github.com/h44z/wg-portal/internal/domain" +) type LoginProviderInfo struct { Identifier string `json:"Identifier" example:"google"` @@ -39,3 +44,32 @@ type OauthInitiationResponse struct { RedirectUrl string State string } + +type WebAuthnCredentialRequest struct { + Name string `json:"Name"` +} +type WebAuthnCredentialResponse struct { + ID string `json:"ID"` + Name string `json:"Name"` + CreatedAt string `json:"CreatedAt"` +} + +func NewWebAuthnCredentialResponse(src domain.UserWebauthnCredential) WebAuthnCredentialResponse { + return WebAuthnCredentialResponse{ + ID: src.CredentialIdentifier, + Name: src.DisplayName, + CreatedAt: src.CreatedAt.Format("2006-01-02 15:04:05"), + } +} + +func NewWebAuthnCredentialResponses(src []domain.UserWebauthnCredential) []WebAuthnCredentialResponse { + credentials := make([]WebAuthnCredentialResponse, len(src)) + for i := range src { + credentials[i] = NewWebAuthnCredentialResponse(src[i]) + } + // Sort by CreatedAt, newest first + slices.SortFunc(credentials, func(i, j WebAuthnCredentialResponse) int { + return strings.Compare(i.CreatedAt, j.CreatedAt) + }) + return credentials +} diff --git a/internal/app/auth/webauthn.go b/internal/app/auth/webauthn.go new file mode 100644 index 0000000..f9c9874 --- /dev/null +++ b/internal/app/auth/webauthn.go @@ -0,0 +1,301 @@ +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + + "github.com/h44z/wg-portal/internal/app" + "github.com/h44z/wg-portal/internal/app/audit" + "github.com/h44z/wg-portal/internal/config" + "github.com/h44z/wg-portal/internal/domain" +) + +type WebAuthnUserManager interface { + // GetUser returns a user by its identifier. + GetUser(context.Context, domain.UserIdentifier) (*domain.User, error) + // GetUserByWebAuthnCredential returns a user by its WebAuthn ID. + GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) + // UpdateUser updates an existing user in the database. + UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error) +} + +type WebAuthnAuthenticator struct { + webAuthn *webauthn.WebAuthn + users WebAuthnUserManager + bus EventBus +} + +func NewWebAuthnAuthenticator(cfg *config.Config, bus EventBus, users WebAuthnUserManager) ( + *WebAuthnAuthenticator, + error, +) { + if !cfg.Auth.WebAuthn.Enabled { + return nil, nil + } + + extUrl, err := url.Parse(cfg.Web.ExternalUrl) + if err != nil { + return nil, errors.New("failed to parse external URL - required for WebAuthn RP ID") + } + + rpId := extUrl.Hostname() + if rpId == "" { + return nil, errors.New("failed to determine Webauthn RPID") + } + + // Initialize the WebAuthn authenticator with the provided configuration + awCfg := &webauthn.Config{ + RPID: rpId, + RPDisplayName: cfg.Web.SiteTitle, + RPOrigins: []string{cfg.Web.ExternalUrl}, + } + + webAuthn, err := webauthn.New(awCfg) + if err != nil { + return nil, fmt.Errorf("failed to create Webauthn instance: %w", err) + } + + return &WebAuthnAuthenticator{ + webAuthn: webAuthn, + users: users, + bus: bus, + }, nil +} + +func (a *WebAuthnAuthenticator) Enabled() bool { + return a != nil && a.webAuthn != nil +} + +func (a *WebAuthnAuthenticator) StartWebAuthnRegistration(ctx context.Context, userId domain.UserIdentifier) ( + optionsAsJSON []byte, + sessionDataAsJSON []byte, + err error, +) { + user, err := a.users.GetUser(ctx, userId) + if err != nil { + return nil, nil, fmt.Errorf("failed to get user: %w", err) + } + if user.IsLocked() || user.IsDisabled() { + return nil, nil, errors.New("user is locked") // adding passkey to locked user is not allowed + } + + if user.WebAuthnId == "" { + user.GenerateWebAuthnId() + user, err = a.users.UpdateUser(ctx, user) + if err != nil { + return nil, nil, fmt.Errorf("failed to store webauthn id to user: %w", err) + } + } + + options, sessionData, err := a.webAuthn.BeginRegistration(user, + webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err) + } + + optionsAsJSON, err = json.Marshal(options) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal webauthn options to JSON: %w", err) + } + sessionDataAsJSON, err = json.Marshal(sessionData) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal webauthn session data to JSON: %w", err) + } + + return optionsAsJSON, sessionDataAsJSON, nil +} + +func (a *WebAuthnAuthenticator) FinishWebAuthnRegistration( + ctx context.Context, + userId domain.UserIdentifier, + name string, + sessionDataAsJSON []byte, + r *http.Request, +) ([]domain.UserWebauthnCredential, error) { + user, err := a.users.GetUser(ctx, userId) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + if user.IsLocked() || user.IsDisabled() { + return nil, errors.New("user is locked") // adding passkey to locked user is not allowed + } + + var webAuthnData webauthn.SessionData + err = json.Unmarshal(sessionDataAsJSON, &webAuthnData) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal webauthn session data: %w", err) + } + + credential, err := a.webAuthn.FinishRegistration(user, webAuthnData, r) + if err != nil { + return nil, err + } + + if name == "" { + name = fmt.Sprintf("Passkey %d", len(user.WebAuthnCredentialList)+1) // fallback name + } + + // Add the credential to the user + err = user.AddCredential(userId, name, *credential) + if err != nil { + return nil, err + } + + user, err = a.users.UpdateUser(ctx, user) + if err != nil { + return nil, err + } + + return user.WebAuthnCredentialList, nil +} + +func (a *WebAuthnAuthenticator) GetCredentials( + ctx context.Context, + userId domain.UserIdentifier, +) ([]domain.UserWebauthnCredential, error) { + user, err := a.users.GetUser(ctx, userId) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + + return user.WebAuthnCredentialList, nil +} + +func (a *WebAuthnAuthenticator) RemoveCredential( + ctx context.Context, + userId domain.UserIdentifier, + credentialIdBase64 string, +) ([]domain.UserWebauthnCredential, error) { + user, err := a.users.GetUser(ctx, userId) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + + user.RemoveCredential(credentialIdBase64) + user, err = a.users.UpdateUser(ctx, user) + if err != nil { + return nil, err + } + + return user.WebAuthnCredentialList, nil +} + +func (a *WebAuthnAuthenticator) UpdateCredential( + ctx context.Context, + userId domain.UserIdentifier, + credentialIdBase64 string, + name string, +) ([]domain.UserWebauthnCredential, error) { + user, err := a.users.GetUser(ctx, userId) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + + err = user.UpdateCredential(credentialIdBase64, name) + if err != nil { + return nil, err + } + + user, err = a.users.UpdateUser(ctx, user) + if err != nil { + return nil, err + } + + return user.WebAuthnCredentialList, nil +} + +func (a *WebAuthnAuthenticator) StartWebAuthnLogin(_ context.Context) ( + optionsAsJSON []byte, + sessionDataAsJSON []byte, + err error, +) { + options, sessionData, err := a.webAuthn.BeginDiscoverableLogin() + if err != nil { + return nil, nil, fmt.Errorf("failed to begin WebAuthn login: %w", err) + } + + optionsAsJSON, err = json.Marshal(options) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal webauthn options to JSON: %w", err) + } + sessionDataAsJSON, err = json.Marshal(sessionData) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal webauthn session data to JSON: %w", err) + } + + return optionsAsJSON, sessionDataAsJSON, nil +} + +func (a *WebAuthnAuthenticator) FinishWebAuthnLogin( + ctx context.Context, + sessionDataAsJSON []byte, + r *http.Request, +) (*domain.User, error) { + + var webAuthnData webauthn.SessionData + err := json.Unmarshal(sessionDataAsJSON, &webAuthnData) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal webauthn session data: %w", err) + } + + // switch to admin context for user lookup + ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo()) + + credential, err := a.webAuthn.FinishDiscoverableLogin(a.findUserForWebAuthnSecretFn(ctx), webAuthnData, r) + if err != nil { + return nil, err + } + + // Find the user by the WebAuthn ID + user, err := a.users.GetUserByWebAuthnCredential(ctx, + base64.StdEncoding.EncodeToString(credential.ID)) + if err != nil { + return nil, fmt.Errorf("failed to get user by webauthn credential: %w", err) + } + + if user.IsLocked() || user.IsDisabled() { + a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{ + Ctx: ctx, + Source: "passkey", + Event: audit.AuthEvent{ + Username: string(user.Identifier), Error: "User is locked", + }, + }) + return nil, errors.New("user is locked") // login with passkey is not allowed + } + + a.bus.Publish(app.TopicAuthLogin, user.Identifier) + a.bus.Publish(app.TopicAuditLoginSuccess, domain.AuditEventWrapper[audit.AuthEvent]{ + Ctx: ctx, + Source: "passkey", + Event: audit.AuthEvent{ + Username: string(user.Identifier), + }, + }) + + return user, nil +} + +func (a *WebAuthnAuthenticator) findUserForWebAuthnSecretFn(ctx context.Context) func(rawID, userHandle []byte) ( + user webauthn.User, + err error, +) { + return func(rawID, userHandle []byte) (webauthn.User, error) { + // Find the user by the WebAuthn ID + user, err := a.users.GetUserByWebAuthnCredential(ctx, base64.StdEncoding.EncodeToString(rawID)) + if err != nil { + return nil, fmt.Errorf("failed to get user by webauthn credential: %w", err) + } + + return user, nil + } +} diff --git a/internal/app/users/user_manager.go b/internal/app/users/user_manager.go index 9511e32..cd8fb5b 100644 --- a/internal/app/users/user_manager.go +++ b/internal/app/users/user_manager.go @@ -25,6 +25,8 @@ type UserDatabaseRepo interface { GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) // GetUserByEmail returns the user with the given email address. GetUserByEmail(ctx context.Context, email string) (*domain.User, error) + // GetUserByWebAuthnCredential returns the user for the given WebAuthn credential ID. + GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) // GetAllUsers returns all users. GetAllUsers(ctx context.Context) ([]domain.User, error) // FindUsers returns all users matching the search string. @@ -129,6 +131,25 @@ func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User return user, nil } +// GetUserByWebAuthnCredential returns the user for the given WebAuthn credential. +func (m Manager) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) { + + user, err := m.users.GetUserByWebAuthnCredential(ctx, credentialIdBase64) + if err != nil { + return nil, fmt.Errorf("unable to load user for webauthn credential %s: %w", credentialIdBase64, err) + } + + if err := domain.ValidateUserAccessRights(ctx, user.Identifier); err != nil { + return nil, err + } + + peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case + + user.LinkedPeerCount = len(peers) + + return user, nil +} + // GetAllUsers returns all users. func (m Manager) GetAllUsers(ctx context.Context) ([]domain.User, error) { if err := domain.ValidateAdminAccessRights(ctx); err != nil { diff --git a/internal/config/auth.go b/internal/config/auth.go index 3132fb7..ba68b12 100644 --- a/internal/config/auth.go +++ b/internal/config/auth.go @@ -16,6 +16,8 @@ type Auth struct { OAuth []OAuthProvider `yaml:"oauth"` // Ldap contains a list of LDAP providers. Ldap []LdapProvider `yaml:"ldap"` + // Webauthn contains the configuration for the WebAuthn authenticator. + WebAuthn WebauthnConfig `yaml:"webauthn"` } // BaseFields contains the basic fields that are used to map user information from the authentication providers. @@ -245,3 +247,9 @@ type OAuthProvider struct { // If LogUserInfo is set to true, the user info retrieved from the OAuth provider will be logged in trace level. LogUserInfo bool `yaml:"log_user_info"` } + +// WebauthnConfig contains the configuration for the WebAuthn authenticator. +type WebauthnConfig struct { + // Enabled specifies whether WebAuthn is enabled. + Enabled bool `yaml:"enabled"` +} diff --git a/internal/config/config.go b/internal/config/config.go index dedebac..2096a3f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -164,6 +164,8 @@ func defaultConfig() *Config { cfg.Webhook.Authentication = "" cfg.Webhook.Timeout = 10 * time.Second + cfg.Auth.WebAuthn.Enabled = true + return cfg } diff --git a/internal/domain/user.go b/internal/domain/user.go index c85e78e..43a8b47 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -2,9 +2,16 @@ package domain import ( "crypto/subtle" + "encoding/base64" + "encoding/json" "errors" + "fmt" + "slices" + "strings" "time" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -43,6 +50,10 @@ type User struct { Locked *time.Time `gorm:"index;column:locked"` // if this field is set, the user is locked and can no longer login (WireGuard peers still can connect) LockedReason string // the reason why the user has been locked + // Passwordless authentication + WebAuthnId string `gorm:"column:webauthn_id"` // the webauthn id of the user, used for webauthn authentication + 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"` ApiTokenCreated *time.Time @@ -157,3 +168,148 @@ func (u *User) CopyCalculatedAttributes(src *User) { u.BaseModel = src.BaseModel u.LinkedPeerCount = src.LinkedPeerCount } + +// region webauthn + +func (u *User) WebAuthnID() []byte { + decodeString, err := base64.StdEncoding.DecodeString(u.WebAuthnId) + if err != nil { + return nil + } + + return decodeString +} + +func (u *User) GenerateWebAuthnId() { + randomUid1 := uuid.New().String() // 32 hex digits + 4 dashes + randomUid2 := uuid.New().String() // 32 hex digits + 4 dashes + webAuthnId := []byte(strings.ReplaceAll(fmt.Sprintf("%s%s", randomUid1, randomUid2), "-", "")) // 64 hex digits + + u.WebAuthnId = base64.StdEncoding.EncodeToString(webAuthnId) +} + +func (u *User) WebAuthnName() string { + return string(u.Identifier) +} + +func (u *User) WebAuthnDisplayName() string { + var userName string + switch { + case u.Firstname != "" && u.Lastname != "": + userName = fmt.Sprintf("%s %s", u.Firstname, u.Lastname) + case u.Firstname != "": + userName = u.Firstname + case u.Lastname != "": + userName = u.Lastname + default: + userName = string(u.Identifier) + } + + return userName +} + +func (u *User) WebAuthnCredentials() []webauthn.Credential { + credentials := make([]webauthn.Credential, len(u.WebAuthnCredentialList)) + for i, cred := range u.WebAuthnCredentialList { + credential, err := cred.GetCredential() + if err != nil { + continue + } + credentials[i] = credential + } + return credentials +} + +func (u *User) AddCredential(userId UserIdentifier, name string, credential webauthn.Credential) error { + cred, err := NewUserWebauthnCredential(userId, name, credential) + if err != nil { + return err + } + + // Check if the credential already exists + for _, c := range u.WebAuthnCredentialList { + if c.GetCredentialId() == string(credential.ID) { + return errors.New("credential already exists") + } + } + + u.WebAuthnCredentialList = append(u.WebAuthnCredentialList, cred) + return nil +} + +func (u *User) UpdateCredential(credentialIdBase64, name string) error { + for i, c := range u.WebAuthnCredentialList { + if c.CredentialIdentifier == credentialIdBase64 { + u.WebAuthnCredentialList[i].DisplayName = name + return nil + } + } + + return errors.New("credential not found") +} + +func (u *User) RemoveCredential(credentialIdBase64 string) { + u.WebAuthnCredentialList = slices.DeleteFunc(u.WebAuthnCredentialList, func(e UserWebauthnCredential) bool { + return e.CredentialIdentifier == credentialIdBase64 + }) +} + +type UserWebauthnCredential struct { + UserIdentifier string `gorm:"primaryKey;column:user_identifier"` // the user identifier + CredentialIdentifier string `gorm:"primaryKey;uniqueIndex;column:credential_identifier"` // base64 encoded credential id + CreatedAt time.Time `gorm:"column:created_at"` // the time when the credential was created + DisplayName string `gorm:"column:display_name"` // the display name of the credential + SerializedCredential string `gorm:"column:serialized_credential"` // JSON and base64 encoded credential +} + +func NewUserWebauthnCredential(userIdentifier UserIdentifier, name string, credential webauthn.Credential) ( + UserWebauthnCredential, + error, +) { + c := UserWebauthnCredential{ + UserIdentifier: string(userIdentifier), + CreatedAt: time.Now(), + DisplayName: name, + CredentialIdentifier: base64.StdEncoding.EncodeToString(credential.ID), + } + + err := c.SetCredential(credential) + if err != nil { + return c, err + } + + return c, nil +} + +func (c *UserWebauthnCredential) SetCredential(credential webauthn.Credential) error { + jsonData, err := json.Marshal(credential) + if err != nil { + return fmt.Errorf("failed to marshal credential: %w", err) + } + + c.SerializedCredential = base64.StdEncoding.EncodeToString(jsonData) + + return nil +} + +func (c *UserWebauthnCredential) GetCredential() (webauthn.Credential, error) { + jsonData, err := base64.StdEncoding.DecodeString(c.SerializedCredential) + if err != nil { + return webauthn.Credential{}, fmt.Errorf("failed to decode base64 credential: %w", err) + } + + var credential webauthn.Credential + if err := json.Unmarshal(jsonData, &credential); err != nil { + return webauthn.Credential{}, fmt.Errorf("failed to unmarshal credential: %w", err) + } + + return credential, nil +} + +func (c *UserWebauthnCredential) GetCredentialId() string { + decodeString, _ := base64.StdEncoding.DecodeString(c.CredentialIdentifier) + + return string(decodeString) +} + +// endregion webauthn From e9005b1b907fde9764b418215b348996df363b9d Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Fri, 16 May 2025 09:55:35 +0200 Subject: [PATCH 02/69] add minimum password length check --- docs/documentation/configuration/overview.md | 17 +++++++--- frontend/src/App.vue | 24 ++++++++++++-- frontend/src/components/UserEditModal.vue | 31 +++++++++++++++++-- frontend/src/lang/translations/de.json | 3 +- frontend/src/lang/translations/en.json | 3 +- internal/app/api/v0/handlers/base.go | 2 ++ .../app/api/v0/handlers/endpoint_config.go | 3 +- .../app/api/v0/handlers/web_authentication.go | 26 ++++++++++++++++ internal/app/api/v0/model/models.go | 1 + internal/app/users/user_manager.go | 10 +++++- internal/config/auth.go | 3 ++ internal/config/config.go | 3 +- internal/domain/user.go | 16 ++++++++++ 13 files changed, 129 insertions(+), 13 deletions(-) diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 3cbdb59..158d060 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -14,7 +14,7 @@ Configuration examples are available on the [Examples](./examples.md) page. ```yaml core: admin_user: admin@wgportal.local - admin_password: wgportal + admin_password: wgportal-default admin_api_token: "" editable_keys: true create_default_peer: false @@ -74,6 +74,7 @@ auth: ldap: [] webauthn: enabled: true + min_password_length: 16 web: listening_address: :8888 @@ -120,9 +121,9 @@ More advanced options are found in the subsequent `Advanced` section. - **Description:** The administrator user. This user will be created as a default admin if it does not yet exist. ### `admin_password` -- **Default:** `wgportal` -- **Description:** The administrator password. The default password of `wgportal` should be changed immediately. -- **Important:** The password should be strong and secure. It is recommended to use a password with at least 16 characters, including uppercase and lowercase letters, numbers, and special characters. +- **Default:** `wgportal-default` +- **Description:** The administrator password. The default password should be changed immediately! +- **Important:** The password should be strong and secure. The minimum password length is specified in [auth.min_password_length](#min_password_length). By default, it is 16 characters. ### `admin_api_token` - **Default:** *(empty)* @@ -340,6 +341,14 @@ Options for configuring email notifications or sending peer configurations via e WireGuard Portal supports multiple authentication strategies, including **OpenID Connect** (`oidc`), **OAuth** (`oauth`), **Passkeys** (`webauthn`) and **LDAP** (`ldap`). Each can have multiple providers configured. Below are the relevant keys. +Some core authentication options are shared across all providers, while others are specific to each provider type. + +### `min_password_length` +- **Default:** `16` +- **Description:** Minimum password length for local authentication. This is not enforced for LDAP authentication. + The default admin password strength is also enforced by this setting. +- **Important:** The password should be strong and secure. It is recommended to use a password with at least 16 characters, including uppercase and lowercase letters, numbers, and special characters. + --- ### OIDC diff --git a/frontend/src/App.vue b/frontend/src/App.vue index cffbb8a..e6e20d2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -61,6 +61,26 @@ const companyName = ref(WGPORTAL_SITE_COMPANY_NAME); const wgVersion = ref(WGPORTAL_VERSION); const currentYear = ref(new Date().getFullYear()) +const userDisplayName = computed(() => { + let displayName = "Unknown"; + if (auth.IsAuthenticated) { + if (auth.User.Firstname === "" && auth.User.Lastname === "") { + displayName = auth.User.Identifier; + } else if (auth.User.Firstname === "" && auth.User.Lastname !== "") { + displayName = auth.User.Lastname; + } else if (auth.User.Firstname !== "" && auth.User.Lastname === "") { + displayName = auth.User.Firstname; + } else if (auth.User.Firstname !== "" && auth.User.Lastname !== "") { + displayName = auth.User.Firstname + " " + auth.User.Lastname; + } + } + + // pad string to 20 characters so that the menu is always the same size on desktop + if (displayName.length < 20 && window.innerWidth > 992) { + displayName = displayName.padStart(20, "\u00A0"); + } + return displayName; +}) diff --git a/frontend/src/lang/translations/de.json b/frontend/src/lang/translations/de.json index 3a172d6..ff8af74 100644 --- a/frontend/src/lang/translations/de.json +++ b/frontend/src/lang/translations/de.json @@ -296,7 +296,8 @@ "password": { "label": "Passwort", "placeholder": "Ein super geheimes Passwort", - "description": "Lassen Sie dieses Feld leer, um das aktuelle Passwort beizubehalten." + "description": "Lassen Sie dieses Feld leer, um das aktuelle Passwort beizubehalten.", + "too-weak": "Das Passwort entspricht nicht den Sicherheitsanforderungen." }, "email": { "label": "E-Mail", diff --git a/frontend/src/lang/translations/en.json b/frontend/src/lang/translations/en.json index 4ee8199..06e95e0 100644 --- a/frontend/src/lang/translations/en.json +++ b/frontend/src/lang/translations/en.json @@ -296,7 +296,8 @@ "password": { "label": "Password", "placeholder": "A super secret password", - "description": "Leave this field blank to keep current password." + "description": "Leave this field blank to keep current password.", + "too-weak": "The password is too weak. Please use a stronger password." }, "email": { "label": "Email", diff --git a/internal/app/api/v0/handlers/base.go b/internal/app/api/v0/handlers/base.go index 38ac024..bee1d05 100644 --- a/internal/app/api/v0/handlers/base.go +++ b/internal/app/api/v0/handlers/base.go @@ -99,6 +99,8 @@ type Authenticator interface { LoggedIn(scopes ...Scope) func(next http.Handler) http.Handler // UserIdMatch checks if the user id in the session matches the user id in the request. If not, the request is aborted. UserIdMatch(idParameter string) func(next http.Handler) http.Handler + // InfoOnly only add user info to the request context. No login check is performed. + InfoOnly() func(next http.Handler) http.Handler } type Session interface { diff --git a/internal/app/api/v0/handlers/endpoint_config.go b/internal/app/api/v0/handlers/endpoint_config.go index d791f88..a99effe 100644 --- a/internal/app/api/v0/handlers/endpoint_config.go +++ b/internal/app/api/v0/handlers/endpoint_config.go @@ -47,7 +47,7 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) { apiGroup := g.Mount("/config") apiGroup.HandleFunc("GET /frontend.js", e.handleConfigJsGet()) - apiGroup.HandleFunc("GET /settings", e.handleSettingsGet()) + apiGroup.With(e.authenticator.InfoOnly()).HandleFunc("GET /settings", e.handleSettingsGet()) } // handleConfigJsGet returns a gorm Handler function. @@ -108,6 +108,7 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc { SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed, ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly, WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, + MinPasswordLength: e.cfg.Auth.MinPasswordLength, }) } } diff --git a/internal/app/api/v0/handlers/web_authentication.go b/internal/app/api/v0/handlers/web_authentication.go index 1b6a570..f214739 100644 --- a/internal/app/api/v0/handlers/web_authentication.go +++ b/internal/app/api/v0/handlers/web_authentication.go @@ -72,6 +72,32 @@ func (h AuthenticationHandler) LoggedIn(scopes ...Scope) func(next http.Handler) } } +// InfoOnly only checks if the user is logged in and adds the user id to the context. +// If the user is not logged in, the context user id is set to domain.CtxUnknownUserId. +func (h AuthenticationHandler) InfoOnly() func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := h.session.GetData(r.Context()) + + var newContext context.Context + + if !session.LoggedIn { + newContext = domain.SetUserInfo(r.Context(), domain.DefaultContextUserInfo()) + } else { + newContext = domain.SetUserInfo(r.Context(), &domain.ContextUserInfo{ + Id: domain.UserIdentifier(session.UserIdentifier), + IsAdmin: session.IsAdmin, + }) + } + + r = r.WithContext(newContext) + + // Continue down the chain to Handler etc + next.ServeHTTP(w, r) + }) + } +} + // UserIdMatch checks if the user id in the session matches the user id in the request. If not, the request is aborted. func (h AuthenticationHandler) UserIdMatch(idParameter string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { diff --git a/internal/app/api/v0/model/models.go b/internal/app/api/v0/model/models.go index a6bbc75..847b139 100644 --- a/internal/app/api/v0/model/models.go +++ b/internal/app/api/v0/model/models.go @@ -11,4 +11,5 @@ type Settings struct { SelfProvisioning bool `json:"SelfProvisioning"` ApiAdminOnly bool `json:"ApiAdminOnly"` WebAuthnEnabled bool `json:"WebAuthnEnabled"` + MinPasswordLength int `json:"MinPasswordLength"` } diff --git a/internal/app/users/user_manager.go b/internal/app/users/user_manager.go index cd8fb5b..11e8ac8 100644 --- a/internal/app/users/user_manager.go +++ b/internal/app/users/user_manager.go @@ -364,6 +364,10 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Use return errors.Join(fmt.Errorf("no access: %w", err), domain.ErrInvalidData) } + if err := new.HasWeakPassword(m.cfg.Auth.MinPasswordLength); err != nil { + return errors.Join(fmt.Errorf("password too weak: %w", err), domain.ErrInvalidData) + } + if currentUser.Id == old.Identifier && old.IsAdmin && !new.IsAdmin { return fmt.Errorf("cannot remove own admin rights: %w", domain.ErrInvalidData) } @@ -418,7 +422,11 @@ func (m Manager) validateCreation(ctx context.Context, new *domain.User) error { // database users must have a password if new.Source == domain.UserSourceDatabase && string(new.Password) == "" { - return fmt.Errorf("invalid password: %w", domain.ErrInvalidData) + return fmt.Errorf("missing password: %w", domain.ErrInvalidData) + } + + if err := new.HasWeakPassword(m.cfg.Auth.MinPasswordLength); err != nil { + return errors.Join(fmt.Errorf("password too weak: %w", err), domain.ErrInvalidData) } return nil diff --git a/internal/config/auth.go b/internal/config/auth.go index ba68b12..004fc5b 100644 --- a/internal/config/auth.go +++ b/internal/config/auth.go @@ -18,6 +18,9 @@ type Auth struct { Ldap []LdapProvider `yaml:"ldap"` // Webauthn contains the configuration for the WebAuthn authenticator. WebAuthn WebauthnConfig `yaml:"webauthn"` + // MinPasswordLength is the minimum password length for user accounts. This also applies to the admin user. + // It is encouraged to set this value to at least 16 characters. + MinPasswordLength int `yaml:"min_password_length"` } // BaseFields contains the basic fields that are used to map user information from the authentication providers. diff --git a/internal/config/config.go b/internal/config/config.go index 2096a3f..cb4d995 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -101,7 +101,7 @@ func defaultConfig() *Config { cfg := &Config{} cfg.Core.AdminUser = "admin@wgportal.local" - cfg.Core.AdminPassword = "wgportal" + cfg.Core.AdminPassword = "wgportal-default" cfg.Core.AdminApiToken = "" // by default, the API access is disabled cfg.Core.ImportExisting = true cfg.Core.RestoreState = true @@ -165,6 +165,7 @@ func defaultConfig() *Config { cfg.Webhook.Timeout = 10 * time.Second cfg.Auth.WebAuthn.Enabled = true + cfg.Auth.MinPasswordLength = 16 return cfg } diff --git a/internal/domain/user.go b/internal/domain/user.go index 43a8b47..84b5345 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -88,6 +88,22 @@ func (u *User) CanChangePassword() error { return errors.New("password change only allowed for database source") } +func (u *User) HasWeakPassword(minLength int) error { + if u.Source != UserSourceDatabase { + return nil // password is not required for non-database users, so no check needed + } + + if u.Password == "" { + return nil // password is not set, so no check needed + } + + if len(u.Password) < minLength { + return fmt.Errorf("password is too short, minimum length is %d", minLength) + } + + return nil // password is strong enough +} + func (u *User) EditAllowed(new *User) error { if u.Source == UserSourceDatabase { return nil From 75a5f3d81597b2e17fd4a0d10addd53f221b6a2f Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Fri, 16 May 2025 14:58:05 +0200 Subject: [PATCH 03/69] add/improve documentation --- README.md | 2 +- docs/assets/images/interface_view.png | Bin 0 -> 137679 bytes docs/assets/images/landing_page.png | Bin 0 -> 112478 bytes docs/assets/images/passkey_setup.png | Bin 0 -> 108988 bytes docs/documentation/configuration/overview.md | 2 + docs/documentation/usage/general.md | 57 +++++++ docs/documentation/usage/ldap.md | 37 +++++ docs/documentation/usage/security.md | 160 +++++++++++++++++++ frontend/src/views/HomeView.vue | 2 +- mkdocs.yml | 6 +- 10 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 docs/assets/images/interface_view.png create mode 100644 docs/assets/images/landing_page.png create mode 100644 docs/assets/images/passkey_setup.png create mode 100644 docs/documentation/usage/general.md create mode 100644 docs/documentation/usage/ldap.md create mode 100644 docs/documentation/usage/security.md diff --git a/README.md b/README.md index c5a2968..018b748 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos * Sends email to the client with QR-code and client config * Enable / Disable clients seamlessly * Generation of wg-quick configuration file (`wgX.conf`) if required -* User authentication (database, OAuth, or LDAP) +* User authentication (database, OAuth, or LDAP), Passkey support * IPv6 ready * Docker ready * Can be used with existing WireGuard setups diff --git a/docs/assets/images/interface_view.png b/docs/assets/images/interface_view.png new file mode 100644 index 0000000000000000000000000000000000000000..09f5b1b8ad07b73b56f73cc8d14fbdda545dff0c GIT binary patch literal 137679 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 diff --git a/mkdocs.yml b/mkdocs.yml index 9bafbe5..e6b988b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,6 +78,10 @@ nav: - Configuration: - Overview: documentation/configuration/overview.md - Examples: documentation/configuration/examples.md + - Usage: + - General: documentation/usage/general.md + - LDAP: documentation/usage/ldap.md + - Security: documentation/usage/security.md + - REST API: documentation/rest-api/api-doc.md - Upgrade: documentation/upgrade/v1.md - Monitoring: documentation/monitoring/prometheus.md - - REST API: documentation/rest-api/api-doc.md From 61d8aa658996cf563a1a5c2db1d99bcfc7de7162 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Sun, 8 Jun 2025 11:17:04 +0200 Subject: [PATCH 04/69] fix self-provisioned peer-generation (#452) --- internal/domain/peer.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/domain/peer.go b/internal/domain/peer.go index 4e40a83..8de712a 100644 --- a/internal/domain/peer.go +++ b/internal/domain/peer.go @@ -136,6 +136,7 @@ func (p *Peer) OverwriteUserEditableFields(userPeer *Peer, cfg *config.Config) { p.Interface.PublicKey = userPeer.Interface.PublicKey p.Interface.PrivateKey = userPeer.Interface.PrivateKey p.PresharedKey = userPeer.PresharedKey + p.Identifier = userPeer.Identifier } p.Interface.Mtu = userPeer.Interface.Mtu p.PersistentKeepalive = userPeer.PersistentKeepalive From e3b65ca3370cdad96c414047a0a297fe41fe0119 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Tue, 10 Jun 2025 17:51:45 +0200 Subject: [PATCH 05/69] improve logging of OAuth login issues, decrease auth-code exchange timeout (#451) --- .../app/api/core/assets/doc/v0_swagger.json | 95 ++++++++++--------- .../app/api/core/assets/doc/v0_swagger.yaml | 36 +++---- .../v0/handlers/endpoint_authentication.go | 15 ++- 3 files changed, 80 insertions(+), 66 deletions(-) diff --git a/internal/app/api/core/assets/doc/v0_swagger.json b/internal/app/api/core/assets/doc/v0_swagger.json index 0658046..e7bd238 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.json +++ b/internal/app/api/core/assets/doc/v0_swagger.json @@ -57,6 +57,52 @@ } } }, + "/auth/login/{provider}/callback": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "Handle the OAuth callback.", + "operationId": "auth_handleOauthCallbackGet", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.LoginProviderInfo" + } + } + } + } + } + }, + "/auth/login/{provider}/init": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "Initiate the OAuth login flow.", + "operationId": "auth_handleOauthInitiateGet", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.LoginProviderInfo" + } + } + } + } + } + }, "/auth/logout": { "post": { "produces": [ @@ -275,52 +321,6 @@ } } }, - "/auth/{provider}/callback": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Authentication" - ], - "summary": "Handle the OAuth callback.", - "operationId": "auth_handleOauthCallbackGet", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/model.LoginProviderInfo" - } - } - } - } - } - }, - "/auth/{provider}/init": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Authentication" - ], - "summary": "Initiate the OAuth login flow.", - "operationId": "auth_handleOauthInitiateGet", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/model.LoginProviderInfo" - } - } - } - } - } - }, "/config/frontend.js": { "get": { "produces": [ @@ -2234,6 +2234,9 @@ "MailLinkOnly": { "type": "boolean" }, + "MinPasswordLength": { + "type": "integer" + }, "PersistentConfigSupported": { "type": "boolean" }, diff --git a/internal/app/api/core/assets/doc/v0_swagger.yaml b/internal/app/api/core/assets/doc/v0_swagger.yaml index e8778b2..a76b5ca 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.yaml +++ b/internal/app/api/core/assets/doc/v0_swagger.yaml @@ -383,6 +383,8 @@ definitions: type: boolean MailLinkOnly: type: boolean + MinPasswordLength: + type: integer PersistentConfigSupported: type: boolean SelfProvisioning: @@ -472,7 +474,22 @@ paths: summary: Get all available audit entries. Ordered by timestamp. tags: - Audit - /auth/{provider}/callback: + /auth/login: + post: + operationId: auth_handleLoginPost + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.LoginProviderInfo' + type: array + summary: Get all available external login providers. + tags: + - Authentication + /auth/login/{provider}/callback: get: operationId: auth_handleOauthCallbackGet produces: @@ -487,7 +504,7 @@ paths: summary: Handle the OAuth callback. tags: - Authentication - /auth/{provider}/init: + /auth/login/{provider}/init: get: operationId: auth_handleOauthInitiateGet produces: @@ -502,21 +519,6 @@ paths: summary: Initiate the OAuth login flow. tags: - Authentication - /auth/login: - post: - operationId: auth_handleLoginPost - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/model.LoginProviderInfo' - type: array - summary: Get all available external login providers. - tags: - - Authentication /auth/logout: post: operationId: auth_handleLogoutPost diff --git a/internal/app/api/v0/handlers/endpoint_authentication.go b/internal/app/api/v0/handlers/endpoint_authentication.go index de412e3..26532b0 100644 --- a/internal/app/api/v0/handlers/endpoint_authentication.go +++ b/internal/app/api/v0/handlers/endpoint_authentication.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "log/slog" "net/http" "net/url" "strconv" @@ -189,7 +190,7 @@ func (e AuthEndpoint) handleSessionInfoGet() http.HandlerFunc { // @Summary Initiate the OAuth login flow. // @Produce json // @Success 200 {object} []model.LoginProviderInfo -// @Router /auth/{provider}/init [get] +// @Router /auth/login/{provider}/init [get] func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { currentSession := e.session.GetData(r.Context()) @@ -234,6 +235,8 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc { authCodeUrl, state, nonce, err := e.authService.OauthLoginStep1(context.Background(), provider) if err != nil { + slog.Debug("failed to create oauth auth code URL", + "provider", provider, "error", err) if autoRedirect && e.isValidReturnUrl(returnTo) { redirectToReturn() } else { @@ -268,7 +271,7 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc { // @Summary Handle the OAuth callback. // @Produce json // @Success 200 {object} []model.LoginProviderInfo -// @Router /auth/{provider}/callback [get] +// @Router /auth/login/{provider}/callback [get] func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { currentSession := e.session.GetData(r.Context()) @@ -306,6 +309,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc { oauthState := request.Query(r, "state") if provider != currentSession.OauthProvider { + slog.Debug("invalid oauth provider in callback", + "expected", currentSession.OauthProvider, "got", provider, "state", oauthState) if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { redirectToReturn() } else { @@ -315,6 +320,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc { return } if oauthState != currentSession.OauthState { + slog.Debug("invalid oauth state in callback", + "expected", currentSession.OauthState, "got", oauthState, "provider", provider) if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { redirectToReturn() } else { @@ -324,11 +331,13 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc { return } - loginCtx, cancel := context.WithTimeout(context.Background(), 1000*time.Second) + loginCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // avoid long waits user, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce, oauthCode) cancel() if err != nil { + slog.Debug("failed to process oauth code", + "provider", provider, "state", oauthState, "error", err) if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { redirectToReturn() } else { From 0c8d6223ce2b7646f02185548a8009cfd7399406 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Jun 2025 20:09:49 +0200 Subject: [PATCH 06/69] chore(deps): bump github.com/go-webauthn/webauthn from 0.12.3 to 0.13.0 (#440) Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.12.3 to 0.13.0. - [Release notes](https://github.com/go-webauthn/webauthn/releases) - [Commits](https://github.com/go-webauthn/webauthn/compare/v0.12.3...v0.13.0) --- updated-dependencies: - dependency-name: github.com/go-webauthn/webauthn dependency-version: 0.13.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index b83e54c..080fb33 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-pkgz/routegroup v1.4.1 github.com/go-playground/validator/v10 v10.26.0 - github.com/go-webauthn/webauthn v0.12.3 + github.com/go-webauthn/webauthn v0.13.0 github.com/google/uuid v1.6.0 github.com/prometheus-community/pro-bing v0.7.0 github.com/prometheus/client_golang v1.22.0 @@ -53,12 +53,12 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.9.2 // indirect github.com/go-test/deep v1.1.1 // indirect - github.com/go-webauthn/x v0.1.20 // indirect + github.com/go-webauthn/x v0.1.21 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/go-tpm v0.9.3 // indirect + github.com/google/go-tpm v0.9.5 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.4 // indirect diff --git a/go.sum b/go.sum index c427c3f..b47c192 100644 --- a/go.sum +++ b/go.sum @@ -81,10 +81,10 @@ github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRj github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE= -github.com/go-webauthn/webauthn v0.12.3/go.mod h1:4JRe8Z3W7HIw8NGEWn2fnUwecoDzkkeach/NnvhkqGY= -github.com/go-webauthn/x v0.1.20 h1:brEBDqfiPtNNCdS/peu8gARtq8fIPsHz0VzpPjGvgiw= -github.com/go-webauthn/x v0.1.20/go.mod h1:n/gAc8ssZJGATM0qThE+W+vfgXiMedsWi3wf/C4lld0= +github.com/go-webauthn/webauthn v0.13.0 h1:cJIL1/1l+22UekVhipziAaSgESJxokYkowUqAIsWs0Y= +github.com/go-webauthn/webauthn v0.13.0/go.mod h1:Oy9o2o79dbLKRPZWWgRIOdtBGAhKnDIaBp2PFkICRHs= +github.com/go-webauthn/x v0.1.21 h1:nFbckQxudvHEJn2uy1VEi713MeSpApoAv9eRqsb9AdQ= +github.com/go-webauthn/x v0.1.21/go.mod h1:sEYohtg1zL4An1TXIUIQ5csdmoO+WO0R4R2pGKaHYKA= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= @@ -96,8 +96,8 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc= -github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= +github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= From b6bfa1f6dee7a7543aa6e107fe955c49f27b39f3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Jun 2025 20:10:32 +0200 Subject: [PATCH 07/69] chore(deps): bump golang.org/x/crypto in the golang group (#454) Bumps the golang group with 1 update: [golang.org/x/crypto](https://github.com/golang/crypto). Updates `golang.org/x/crypto` from 0.38.0 to 0.39.0 - [Commits](https://github.com/golang/crypto/compare/v0.38.0...v0.39.0) --- updated-dependencies: - dependency-name: golang.org/x/crypto dependency-version: 0.39.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: golang ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 10 +++++----- go.sum | 24 ++++++++++++------------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 080fb33..cd2ee79 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ 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.38.0 + golang.org/x/crypto v0.39.0 golang.org/x/oauth2 v0.30.0 golang.org/x/sys v0.33.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 @@ -87,10 +87,10 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sync v0.14.0 // indirect - golang.org/x/text v0.25.0 // indirect - golang.org/x/tools v0.32.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/text v0.26.0 // indirect + golang.org/x/tools v0.33.0 // indirect golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect google.golang.org/protobuf v1.36.6 // indirect modernc.org/libc v1.63.0 // indirect diff --git a/go.sum b/go.sum index b47c192..5d1b219 100644 --- a/go.sum +++ b/go.sum @@ -232,14 +232,14 @@ golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -253,15 +253,15 @@ golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 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= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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= @@ -300,14 +300,14 @@ golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 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= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= From cbf8c5bca962858950aa609764fbc8c2bd97b1e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Jun 2025 20:17:12 +0200 Subject: [PATCH 08/69] chore(deps): bump the gorm group across 1 directory with 4 updates (#453) Bumps the gorm group with 3 updates in the / directory: [gorm.io/driver/mysql](https://github.com/go-gorm/mysql), [gorm.io/driver/postgres](https://github.com/go-gorm/postgres) and [gorm.io/driver/sqlserver](https://github.com/go-gorm/sqlserver). Updates `gorm.io/driver/mysql` from 1.5.7 to 1.6.0 - [Commits](https://github.com/go-gorm/mysql/compare/v1.5.7...v1.6.0) Updates `gorm.io/driver/postgres` from 1.5.11 to 1.6.0 - [Commits](https://github.com/go-gorm/postgres/compare/v1.5.11...v1.6.0) Updates `gorm.io/driver/sqlserver` from 1.5.4 to 1.6.0 - [Commits](https://github.com/go-gorm/sqlserver/compare/v1.5.4...v1.6.0) Updates `gorm.io/gorm` from 1.26.1 to 1.30.0 - [Release notes](https://github.com/go-gorm/gorm/releases) - [Commits](https://github.com/go-gorm/gorm/compare/v1.26.1...v1.30.0) --- updated-dependencies: - dependency-name: gorm.io/driver/mysql dependency-version: 1.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gorm - dependency-name: gorm.io/driver/postgres dependency-version: 1.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gorm - dependency-name: gorm.io/driver/sqlserver dependency-version: 1.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gorm - dependency-name: gorm.io/gorm dependency-version: 1.30.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gorm ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 8 ++-- go.sum | 125 ++++++++++++++++++++++++++++++--------------------------- 2 files changed, 70 insertions(+), 63 deletions(-) diff --git a/go.mod b/go.mod index cd2ee79..1f2e60c 100644 --- a/go.mod +++ b/go.mod @@ -26,10 +26,10 @@ require ( golang.org/x/sys v0.33.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 gopkg.in/yaml.v3 v3.0.1 - gorm.io/driver/mysql v1.5.7 - gorm.io/driver/postgres v1.5.11 - gorm.io/driver/sqlserver v1.5.4 - gorm.io/gorm v1.26.1 + gorm.io/driver/mysql v1.6.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlserver v1.6.0 + gorm.io/gorm v1.30.0 ) require ( diff --git a/go.sum b/go.sum index 5d1b219..4b64484 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,13 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0/go.mod h1:bhXu1AjYL+wutSL/kpSq6s7733q2Rb0yuot9Zgfqa/0= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo= github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw= @@ -19,8 +16,7 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= -github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -76,7 +72,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= @@ -85,23 +80,26 @@ github.com/go-webauthn/webauthn v0.13.0 h1:cJIL1/1l+22UekVhipziAaSgESJxokYkowUqA github.com/go-webauthn/webauthn v0.13.0/go.mod h1:Oy9o2o79dbLKRPZWWgRIOdtBGAhKnDIaBp2PFkICRHs= github.com/go-webauthn/x v0.1.21 h1:nFbckQxudvHEJn2uy1VEi713MeSpApoAv9eRqsb9AdQ= github.com/go-webauthn/x v0.1.21/go.mod h1:sEYohtg1zL4An1TXIUIQ5csdmoO+WO0R4R2pGKaHYKA= -github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= @@ -121,10 +119,12 @@ github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFK github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= @@ -157,7 +157,7 @@ github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/ github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= -github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= +github.com/microsoft/go-mssqldb v0.19.0/go.mod h1:ukJCBnnzLzpVF0qYRT+eg1e+eSwjeQ7IvenUv8QPook= github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw= github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= @@ -165,11 +165,12 @@ github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCL github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= -github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= @@ -190,15 +191,10 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= @@ -224,35 +220,38 @@ github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= -golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= -golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= @@ -260,52 +259,60 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl 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= +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.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220224120231-95c6836cb0e7/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= -golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= -golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 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.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 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= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -318,23 +325,23 @@ google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= -gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= -gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= -gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= -gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g= -gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g= -gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw= -gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= +gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA= modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc= From f0be66aea48e6eff73ea6dbe476034d470a98543 Mon Sep 17 00:00:00 2001 From: "S.J. Louw" <29563018+sj-louw@users.noreply.github.com> Date: Thu, 26 Jun 2025 20:17:45 +0200 Subject: [PATCH 09/69] Option to limit peer count that a normal user can create (#457) --- docs/documentation/configuration/overview.md | 5 ++++ internal/app/wireguard/wireguard_peers.go | 24 +++++++++++++++++ internal/config/config.go | 27 +++++++++++--------- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 70496aa..8853af2 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -38,6 +38,7 @@ advanced: rule_prio_offset: 20000 route_table_offset: 20000 api_admin_only: true + limit_additional_user_peers: 0 database: debug: false @@ -215,6 +216,10 @@ Additional or more specialized configuration options for logging and interface c - **Default:** `true` - **Description:** If `true`, the public REST API is accessible only to admin users. The API docs live at [`/api/v1/doc.html`](../rest-api/api-doc.md). +### `limit_additional_user_peers` +- **Default:** `0` +- **Description:** Limit additional peers a normal user can create. `0` means unlimited. + --- ## Database diff --git a/internal/app/wireguard/wireguard_peers.go b/internal/app/wireguard/wireguard_peers.go index 2131323..1406e85 100644 --- a/internal/app/wireguard/wireguard_peers.go +++ b/internal/app/wireguard/wireguard_peers.go @@ -188,6 +188,30 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee sessionUser := domain.GetUserInfo(ctx) + // Enforce peer limit for non-admin users if LimitAdditionalUserPeers is set + if m.cfg.Core.SelfProvisioningAllowed && !sessionUser.IsAdmin && m.cfg.Advanced.LimitAdditionalUserPeers > 0 { + peers, err := m.db.GetUserPeers(ctx, peer.UserIdentifier) + if err != nil { + return nil, fmt.Errorf("failed to fetch peers for user %s: %w", peer.UserIdentifier, err) + } + // Count enabled peers (disabled IS NULL) + peerCount := 0 + for _, p := range peers { + if !p.IsDisabled() { + peerCount++ + } + } + totalAllowedPeers := 1 + m.cfg.Advanced.LimitAdditionalUserPeers // 1 default peer + x additional peers + if peerCount >= totalAllowedPeers { + slog.WarnContext(ctx, "peer creation blocked due to limit", + "user", peer.UserIdentifier, + "current_count", peerCount, + "allowed_count", totalAllowedPeers) + return nil, fmt.Errorf("peer limit reached (%d peers allowed): %w", totalAllowedPeers, domain.ErrNoPermission) + } + } + + existingPeer, err := m.db.GetPeer(ctx, peer.Identifier) if err != nil && !errors.Is(err, domain.ErrNotFound) { return nil, fmt.Errorf("unable to load existing peer %s: %w", peer.Identifier, err) diff --git a/internal/config/config.go b/internal/config/config.go index f8ade2f..66ff746 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,18 +29,19 @@ type Config struct { } `yaml:"core"` Advanced struct { - LogLevel string `yaml:"log_level"` - LogPretty bool `yaml:"log_pretty"` - LogJson bool `yaml:"log_json"` - StartListenPort int `yaml:"start_listen_port"` - StartCidrV4 string `yaml:"start_cidr_v4"` - StartCidrV6 string `yaml:"start_cidr_v6"` - UseIpV6 bool `yaml:"use_ip_v6"` - ConfigStoragePath string `yaml:"config_storage_path"` // keep empty to disable config export to file - ExpiryCheckInterval time.Duration `yaml:"expiry_check_interval"` - RulePrioOffset int `yaml:"rule_prio_offset"` - RouteTableOffset int `yaml:"route_table_offset"` - ApiAdminOnly bool `yaml:"api_admin_only"` // if true, only admin users can access the API + LogLevel string `yaml:"log_level"` + LogPretty bool `yaml:"log_pretty"` + LogJson bool `yaml:"log_json"` + StartListenPort int `yaml:"start_listen_port"` + StartCidrV4 string `yaml:"start_cidr_v4"` + StartCidrV6 string `yaml:"start_cidr_v6"` + UseIpV6 bool `yaml:"use_ip_v6"` + ConfigStoragePath string `yaml:"config_storage_path"` // keep empty to disable config export to file + ExpiryCheckInterval time.Duration `yaml:"expiry_check_interval"` + RulePrioOffset int `yaml:"rule_prio_offset"` + RouteTableOffset int `yaml:"route_table_offset"` + ApiAdminOnly bool `yaml:"api_admin_only"` // if true, only admin users can access the API + LimitAdditionalUserPeers int `yaml:"limit_additional_user_peers"` } `yaml:"advanced"` Statistics struct { @@ -76,6 +77,7 @@ func (c *Config) LogStartupValues() { "reEnablePeerAfterUserEnable", c.Core.ReEnablePeerAfterUserEnable, "deletePeerAfterUserDeleted", c.Core.DeletePeerAfterUserDeleted, "selfProvisioningAllowed", c.Core.SelfProvisioningAllowed, + "limitAdditionalUserPeers", c.Advanced.LimitAdditionalUserPeers, "importExisting", c.Core.ImportExisting, "restoreState", c.Core.RestoreState, "useIpV6", c.Advanced.UseIpV6, @@ -137,6 +139,7 @@ func defaultConfig() *Config { cfg.Advanced.RulePrioOffset = 20000 cfg.Advanced.RouteTableOffset = 20000 cfg.Advanced.ApiAdminOnly = true + cfg.Advanced.LimitAdditionalUserPeers = 0 cfg.Statistics.UsePingChecks = true cfg.Statistics.PingCheckWorkers = 10 From 3a732fd3e5eae78e0882a35a24c145e23d778cea Mon Sep 17 00:00:00 2001 From: HPPinata <83947761+HPPinata@users.noreply.github.com> Date: Thu, 26 Jun 2025 20:20:15 +0200 Subject: [PATCH 10/69] add docker to dependabot (#463) --- .github/dependabot.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1aba90f..007f958 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -28,3 +28,8 @@ updates: patch: update-types: - patch + + - package-ecosystem: "docker" + directory: / + schedule: + interval: weekly From 94785c10ecdc8d49c855ee606e17bd5061463ccd Mon Sep 17 00:00:00 2001 From: h44z Date: Fri, 27 Jun 2025 11:45:44 +0200 Subject: [PATCH 11/69] use website title in mail templates (#448) (#466) * use website title in mail templates (#448) * change button font color to white (#448) --- internal/app/mail/manager.go | 2 +- internal/app/mail/template.go | 6 +++++- internal/app/mail/tpl_files/mail_with_attachment.gohtml | 8 ++++---- internal/app/mail/tpl_files/mail_with_attachment.gotpl | 2 +- internal/app/mail/tpl_files/mail_with_link.gohtml | 8 ++++---- internal/app/mail/tpl_files/mail_with_link.gotpl | 2 +- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/internal/app/mail/manager.go b/internal/app/mail/manager.go index 7b31406..c7ff431 100644 --- a/internal/app/mail/manager.go +++ b/internal/app/mail/manager.go @@ -71,7 +71,7 @@ func NewMailManager( users UserDatabaseRepo, wg WireguardDatabaseRepo, ) (*Manager, error) { - tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl) + tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle) if err != nil { return nil, fmt.Errorf("failed to initialize template handler: %w", err) } diff --git a/internal/app/mail/template.go b/internal/app/mail/template.go index 15253bf..722d534 100644 --- a/internal/app/mail/template.go +++ b/internal/app/mail/template.go @@ -17,11 +17,12 @@ var TemplateFiles embed.FS // TemplateHandler is a struct that holds the html and text templates. type TemplateHandler struct { portalUrl string + portalName string htmlTemplates *htmlTemplate.Template textTemplates *template.Template } -func newTemplateHandler(portalUrl string) (*TemplateHandler, error) { +func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error) { htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml") if err != nil { return nil, fmt.Errorf("failed to parse html template files: %w", err) @@ -34,6 +35,7 @@ func newTemplateHandler(portalUrl string) (*TemplateHandler, error) { handler := &TemplateHandler{ portalUrl: portalUrl, + portalName: portalName, htmlTemplates: htmlTemplateCache, textTemplates: txtTemplateCache, } @@ -81,6 +83,7 @@ func (c TemplateHandler) GetConfigMailWithAttachment(user *domain.User, cfgName, "ConfigFileName": cfgName, "QrcodePngName": qrName, "PortalUrl": c.portalUrl, + "PortalName": c.portalName, }) if err != nil { return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gotpl: %w", err) @@ -91,6 +94,7 @@ func (c TemplateHandler) GetConfigMailWithAttachment(user *domain.User, cfgName, "ConfigFileName": cfgName, "QrcodePngName": qrName, "PortalUrl": c.portalUrl, + "PortalName": c.portalName, }) if err != nil { return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gohtml: %w", err) diff --git a/internal/app/mail/tpl_files/mail_with_attachment.gohtml b/internal/app/mail/tpl_files/mail_with_attachment.gohtml index 0243848..0bc5d70 100644 --- a/internal/app/mail/tpl_files/mail_with_attachment.gohtml +++ b/internal/app/mail/tpl_files/mail_with_attachment.gohtml @@ -19,7 +19,7 @@ - Email Template + {{$.PortalName}} - Email Template + {{$.PortalName}} + + + + +
+
+ +
+
+
+ +
+
+ +
diff --git a/internal/app/api/core/assets/doc/v0_swagger.json b/internal/app/api/core/assets/doc/v0_swagger.json index e7bd238..ad07099 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.json +++ b/internal/app/api/core/assets/doc/v0_swagger.json @@ -2231,6 +2231,9 @@ "ApiAdminOnly": { "type": "boolean" }, + "LoginFormVisible": { + "type": "boolean" + }, "MailLinkOnly": { "type": "boolean" }, diff --git a/internal/app/api/core/assets/doc/v0_swagger.yaml b/internal/app/api/core/assets/doc/v0_swagger.yaml index a76b5ca..a788505 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.yaml +++ b/internal/app/api/core/assets/doc/v0_swagger.yaml @@ -381,6 +381,8 @@ definitions: properties: ApiAdminOnly: type: boolean + LoginFormVisible: + type: boolean MailLinkOnly: type: boolean MinPasswordLength: diff --git a/internal/app/api/v0/handlers/endpoint_config.go b/internal/app/api/v0/handlers/endpoint_config.go index a99effe..21b342a 100644 --- a/internal/app/api/v0/handlers/endpoint_config.go +++ b/internal/app/api/v0/handlers/endpoint_config.go @@ -96,10 +96,13 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sessionUser := domain.GetUserInfo(r.Context()) + hasSocialLogin := len(e.cfg.Auth.OAuth) > 0 || len(e.cfg.Auth.OpenIDConnect) > 0 || e.cfg.Auth.WebAuthn.Enabled + // For anonymous users, we return the settings object with minimal information if sessionUser.Id == domain.CtxUnknownUserId || sessionUser.Id == "" { respond.JSON(w, http.StatusOK, model.Settings{ - WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, + WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, + LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin, }) } else { respond.JSON(w, http.StatusOK, model.Settings{ @@ -109,6 +112,7 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc { ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly, WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, MinPasswordLength: e.cfg.Auth.MinPasswordLength, + LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin, }) } } diff --git a/internal/app/api/v0/model/models.go b/internal/app/api/v0/model/models.go index 847b139..5c3ec73 100644 --- a/internal/app/api/v0/model/models.go +++ b/internal/app/api/v0/model/models.go @@ -12,4 +12,5 @@ type Settings struct { ApiAdminOnly bool `json:"ApiAdminOnly"` WebAuthnEnabled bool `json:"WebAuthnEnabled"` MinPasswordLength int `json:"MinPasswordLength"` + LoginFormVisible bool `json:"LoginFormVisible"` } diff --git a/internal/config/auth.go b/internal/config/auth.go index 004fc5b..ef1b994 100644 --- a/internal/config/auth.go +++ b/internal/config/auth.go @@ -21,6 +21,9 @@ type Auth struct { // MinPasswordLength is the minimum password length for user accounts. This also applies to the admin user. // It is encouraged to set this value to at least 16 characters. MinPasswordLength int `yaml:"min_password_length"` + // HideLoginForm specifies whether the login form should be hidden. If no social login providers are configured, + // the login form will be shown regardless of this setting. + HideLoginForm bool `yaml:"hide_login_form"` } // BaseFields contains the basic fields that are used to map user information from the authentication providers. diff --git a/internal/config/config.go b/internal/config/config.go index 66ff746..e64a703 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -95,6 +95,9 @@ func (c *Config) LogStartupValues() { "oidcProviders", len(c.Auth.OpenIDConnect), "oauthProviders", len(c.Auth.OAuth), "ldapProviders", len(c.Auth.Ldap), + "webauthnEnabled", c.Auth.WebAuthn.Enabled, + "minPasswordLength", c.Auth.MinPasswordLength, + "hideLoginForm", c.Auth.HideLoginForm, ) } @@ -169,6 +172,7 @@ func defaultConfig() *Config { cfg.Auth.WebAuthn.Enabled = true cfg.Auth.MinPasswordLength = 16 + cfg.Auth.HideLoginForm = false return cfg } From f08740991b7a33a245b317d7d4153ce1d192a2a2 Mon Sep 17 00:00:00 2001 From: h44z Date: Sun, 29 Jun 2025 19:47:53 +0200 Subject: [PATCH 15/69] support for raw-wireguard and wg-quick style peer configurations (#441) (#473) --- frontend/src/components/PeerViewModal.vue | 39 +++++++++++++++---- frontend/src/lang/translations/de.json | 3 +- frontend/src/lang/translations/en.json | 3 +- frontend/src/stores/peers.js | 8 ++-- .../app/api/core/assets/doc/v0_swagger.json | 18 +++++++++ .../app/api/core/assets/doc/v0_swagger.yaml | 12 ++++++ internal/app/api/v0/backend/peer_service.go | 26 ++++++++----- .../app/api/v0/handlers/endpoint_peers.go | 29 +++++++++++--- .../api/v1/backend/provisioning_service.go | 8 ++-- internal/app/configfile/manager.go | 10 ++--- internal/app/configfile/template.go | 5 ++- internal/app/configfile/tpl_files/wg_peer.tpl | 16 ++++++-- internal/app/mail/manager.go | 20 ++++++---- internal/domain/base.go | 3 ++ 14 files changed, 149 insertions(+), 51 deletions(-) diff --git a/frontend/src/components/PeerViewModal.vue b/frontend/src/components/PeerViewModal.vue index 686ee8d..91c2d86 100644 --- a/frontend/src/components/PeerViewModal.vue +++ b/frontend/src/components/PeerViewModal.vue @@ -50,7 +50,7 @@ const selectedStats = computed(() => { if (!s) { if (!!props.peerId || props.peerId.length) { - p = profile.Statistics(props.peerId) + s = profile.Statistics(props.peerId) } else { s = freshStats() // dummy stats to avoid 'undefined' exceptions } @@ -79,13 +79,19 @@ const title = computed(() => { } }) +const configStyle = ref("wgquick") + watch(() => props.visible, async (newValue, oldValue) => { if (oldValue === false && newValue === true) { // if modal is shown - await peers.LoadPeerConfig(selectedPeer.value.Identifier) + await peers.LoadPeerConfig(selectedPeer.value.Identifier, configStyle.value) configString.value = peers.configuration } -} -) +}) + +watch(() => configStyle.value, async () => { + await peers.LoadPeerConfig(selectedPeer.value.Identifier, configStyle.value) + configString.value = peers.configuration +}) function download() { // credit: https://www.bitdegree.org/learn/javascript-download @@ -103,7 +109,7 @@ function download() { } function email() { - peers.MailPeerConfig(settings.Setting("MailLinkOnly"), [selectedPeer.value.Identifier]).catch(e => { + peers.MailPeerConfig(settings.Setting("MailLinkOnly"), configStyle.value, [selectedPeer.value.Identifier]).catch(e => { notify({ title: "Failed to send mail with peer configuration!", text: e.toString(), @@ -114,7 +120,7 @@ function email() { function ConfigQrUrl() { if (props.peerId.length) { - return apiWrapper.url(`/peer/config-qr/${base64_url_encode(props.peerId)}`) + return apiWrapper.url(`/peer/config-qr/${base64_url_encode(props.peerId)}?style=${configStyle.value}`) } return '' } @@ -124,6 +130,15 @@ function ConfigQrUrl() { - +} + +.btn-switch-group .btn { + border-width: 1px; + padding: 5px; + line-height: 1; +} + diff --git a/frontend/src/lang/translations/de.json b/frontend/src/lang/translations/de.json index ff8af74..859c70e 100644 --- a/frontend/src/lang/translations/de.json +++ b/frontend/src/lang/translations/de.json @@ -467,7 +467,8 @@ "connected-since": "Verbunden seit", "endpoint": "Endpunkt", "button-download": "Konfiguration herunterladen", - "button-email": "Konfiguration per E-Mail senden" + "button-email": "Konfiguration per E-Mail senden", + "style-label": "Konfigurationsformat" }, "peer-edit": { "headline-edit-peer": "Peer bearbeiten:", diff --git a/frontend/src/lang/translations/en.json b/frontend/src/lang/translations/en.json index 06e95e0..57a129a 100644 --- a/frontend/src/lang/translations/en.json +++ b/frontend/src/lang/translations/en.json @@ -468,7 +468,8 @@ "connected-since": "Connected since", "endpoint": "Endpoint", "button-download": "Download configuration", - "button-email": "Send configuration via E-Mail" + "button-email": "Send configuration via E-Mail", + "style-label": "Configuration Style" }, "peer-edit": { "headline-edit-peer": "Edit peer:", diff --git a/frontend/src/stores/peers.js b/frontend/src/stores/peers.js index c59e4c7..c691a01 100644 --- a/frontend/src/stores/peers.js +++ b/frontend/src/stores/peers.js @@ -142,8 +142,8 @@ export const peerStore = defineStore('peers', { }) }) }, - async MailPeerConfig(linkOnly, ids) { - return apiWrapper.post(`${baseUrl}/config-mail`, { + async MailPeerConfig(linkOnly, style, ids) { + return apiWrapper.post(`${baseUrl}/config-mail?style=${style}`, { Identifiers: ids, LinkOnly: linkOnly }) @@ -158,8 +158,8 @@ export const peerStore = defineStore('peers', { throw new Error(error) }) }, - async LoadPeerConfig(id) { - return apiWrapper.get(`${baseUrl}/config/${base64_url_encode(id)}`) + async LoadPeerConfig(id, style) { + return apiWrapper.get(`${baseUrl}/config/${base64_url_encode(id)}?style=${style}`) .then(this.setPeerConfig) .catch(error => { this.configuration = "" diff --git a/internal/app/api/core/assets/doc/v0_swagger.json b/internal/app/api/core/assets/doc/v0_swagger.json index ad07099..fc2ebc8 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.json +++ b/internal/app/api/core/assets/doc/v0_swagger.json @@ -819,6 +819,12 @@ "schema": { "$ref": "#/definitions/model.PeerMailRequest" } + }, + { + "type": "string", + "description": "The configuration style", + "name": "style", + "in": "query" } ], "responses": { @@ -858,6 +864,12 @@ "name": "id", "in": "path", "required": true + }, + { + "type": "string", + "description": "The configuration style", + "name": "style", + "in": "query" } ], "responses": { @@ -899,6 +911,12 @@ "name": "id", "in": "path", "required": true + }, + { + "type": "string", + "description": "The configuration style", + "name": "style", + "in": "query" } ], "responses": { diff --git a/internal/app/api/core/assets/doc/v0_swagger.yaml b/internal/app/api/core/assets/doc/v0_swagger.yaml index a788505..479cb8d 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.yaml +++ b/internal/app/api/core/assets/doc/v0_swagger.yaml @@ -1072,6 +1072,10 @@ paths: required: true schema: $ref: '#/definitions/model.PeerMailRequest' + - description: The configuration style + in: query + name: style + type: string produces: - application/json responses: @@ -1097,6 +1101,10 @@ paths: name: id required: true type: string + - description: The configuration style + in: query + name: style + type: string produces: - image/png - application/json @@ -1125,6 +1133,10 @@ paths: name: id required: true type: string + - description: The configuration style + in: query + name: style + type: string produces: - application/json responses: diff --git a/internal/app/api/v0/backend/peer_service.go b/internal/app/api/v0/backend/peer_service.go index f590693..ab4a7a8 100644 --- a/internal/app/api/v0/backend/peer_service.go +++ b/internal/app/api/v0/backend/peer_service.go @@ -27,12 +27,12 @@ type PeerServicePeerManager interface { } type PeerServiceConfigFileManager interface { - GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) - GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) + GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) + GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) } type PeerServiceMailManager interface { - SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error + SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error } // endregion dependencies @@ -95,16 +95,24 @@ func (p PeerService) DeletePeer(ctx context.Context, id domain.PeerIdentifier) e return p.peers.DeletePeer(ctx, id) } -func (p PeerService) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) { - return p.configFile.GetPeerConfig(ctx, id) +func (p PeerService) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) { + return p.configFile.GetPeerConfig(ctx, id, style) } -func (p PeerService) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) { - return p.configFile.GetPeerConfigQrCode(ctx, id) +func (p PeerService) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) ( + io.Reader, + error, +) { + return p.configFile.GetPeerConfigQrCode(ctx, id, style) } -func (p PeerService) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error { - return p.mailer.SendPeerEmail(ctx, linkOnly, peers...) +func (p PeerService) SendPeerEmail( + ctx context.Context, + linkOnly bool, + style string, + peers ...domain.PeerIdentifier, +) error { + return p.mailer.SendPeerEmail(ctx, linkOnly, style, peers...) } func (p PeerService) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) { diff --git a/internal/app/api/v0/handlers/endpoint_peers.go b/internal/app/api/v0/handlers/endpoint_peers.go index d9a0115..e60a3aa 100644 --- a/internal/app/api/v0/handlers/endpoint_peers.go +++ b/internal/app/api/v0/handlers/endpoint_peers.go @@ -34,11 +34,11 @@ type PeerService interface { // DeletePeer deletes the peer with the given id. DeletePeer(ctx context.Context, id domain.PeerIdentifier) error // GetPeerConfig returns the peer configuration for the given id. - GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) + GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) // GetPeerConfigQrCode returns the peer configuration as qr code for the given id. - GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) + GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) // SendPeerEmail sends the peer configuration via email. - SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error + SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error // GetPeerStats returns the peer stats for the given interface. GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) } @@ -355,6 +355,7 @@ func (e PeerEndpoint) handleDelete() http.HandlerFunc { // @Summary Get peer configuration as string. // @Produce json // @Param id path string true "The peer identifier" +// @Param style query string false "The configuration style" // @Success 200 {object} string // @Failure 400 {object} model.Error // @Failure 500 {object} model.Error @@ -369,7 +370,9 @@ func (e PeerEndpoint) handleConfigGet() http.HandlerFunc { return } - configTxt, err := e.peerService.GetPeerConfig(r.Context(), domain.PeerIdentifier(id)) + configStyle := e.getConfigStyle(r) + + configTxt, err := e.peerService.GetPeerConfig(r.Context(), domain.PeerIdentifier(id), configStyle) if err != nil { respond.JSON(w, http.StatusInternalServerError, model.Error{ Code: http.StatusInternalServerError, Message: err.Error(), @@ -397,6 +400,7 @@ func (e PeerEndpoint) handleConfigGet() http.HandlerFunc { // @Produce png // @Produce json // @Param id path string true "The peer identifier" +// @Param style query string false "The configuration style" // @Success 200 {file} binary // @Failure 400 {object} model.Error // @Failure 500 {object} model.Error @@ -411,7 +415,9 @@ func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc { return } - configQr, err := e.peerService.GetPeerConfigQrCode(r.Context(), domain.PeerIdentifier(id)) + configStyle := e.getConfigStyle(r) + + configQr, err := e.peerService.GetPeerConfigQrCode(r.Context(), domain.PeerIdentifier(id), configStyle) if err != nil { respond.JSON(w, http.StatusInternalServerError, model.Error{ Code: http.StatusInternalServerError, Message: err.Error(), @@ -438,6 +444,7 @@ func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc { // @Summary Send peer configuration via email. // @Produce json // @Param request body model.PeerMailRequest true "The peer mail request data" +// @Param style query string false "The configuration style" // @Success 204 "No content if mail sending was successful" // @Failure 400 {object} model.Error // @Failure 500 {object} model.Error @@ -460,11 +467,13 @@ func (e PeerEndpoint) handleEmailPost() http.HandlerFunc { return } + configStyle := e.getConfigStyle(r) + peerIds := make([]domain.PeerIdentifier, len(req.Identifiers)) for i := range req.Identifiers { peerIds[i] = domain.PeerIdentifier(req.Identifiers[i]) } - if err := e.peerService.SendPeerEmail(r.Context(), req.LinkOnly, peerIds...); err != nil { + if err := e.peerService.SendPeerEmail(r.Context(), req.LinkOnly, configStyle, peerIds...); err != nil { respond.JSON(w, http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) return @@ -504,3 +513,11 @@ func (e PeerEndpoint) handleStatsGet() http.HandlerFunc { respond.JSON(w, http.StatusOK, model.NewPeerStats(e.cfg.Statistics.CollectPeerData, stats)) } } + +func (e PeerEndpoint) getConfigStyle(r *http.Request) string { + configStyle := request.QueryDefault(r, "style", domain.ConfigStyleWgQuick) + if configStyle != domain.ConfigStyleWgQuick && configStyle != domain.ConfigStyleRaw { + configStyle = domain.ConfigStyleWgQuick // default to wg-quick style + } + return configStyle +} diff --git a/internal/app/api/v1/backend/provisioning_service.go b/internal/app/api/v1/backend/provisioning_service.go index 4e14ecb..88bbfee 100644 --- a/internal/app/api/v1/backend/provisioning_service.go +++ b/internal/app/api/v1/backend/provisioning_service.go @@ -23,8 +23,8 @@ type ProvisioningServicePeerManagerRepo interface { } type ProvisioningServiceConfigFileManagerRepo interface { - GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) - GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) + GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) + GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) } type ProvisioningService struct { @@ -96,7 +96,7 @@ func (p ProvisioningService) GetPeerConfig(ctx context.Context, peerId domain.Pe return nil, err } - peerCfgReader, err := p.configFiles.GetPeerConfig(ctx, peer.Identifier) + peerCfgReader, err := p.configFiles.GetPeerConfig(ctx, peer.Identifier, domain.ConfigStyleWgQuick) if err != nil { return nil, err } @@ -119,7 +119,7 @@ func (p ProvisioningService) GetPeerQrPng(ctx context.Context, peerId domain.Pee return nil, err } - peerCfgQrReader, err := p.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier) + peerCfgQrReader, err := p.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier, domain.ConfigStyleWgQuick) if err != nil { return nil, err } diff --git a/internal/app/configfile/manager.go b/internal/app/configfile/manager.go index 83c07f4..94f06ae 100644 --- a/internal/app/configfile/manager.go +++ b/internal/app/configfile/manager.go @@ -46,7 +46,7 @@ type TemplateRenderer interface { // GetInterfaceConfig returns the configuration file for the given interface. GetInterfaceConfig(iface *domain.Interface, peers []domain.Peer) (io.Reader, error) // GetPeerConfig returns the configuration file for the given peer. - GetPeerConfig(peer *domain.Peer) (io.Reader, error) + GetPeerConfig(peer *domain.Peer, style string) (io.Reader, error) } type EventBus interface { @@ -186,7 +186,7 @@ func (m Manager) GetInterfaceConfig(ctx context.Context, id domain.InterfaceIden // GetPeerConfig returns the configuration file for the given peer. // The file is structured in wg-quick format. -func (m Manager) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) { +func (m Manager) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) { peer, err := m.wg.GetPeer(ctx, id) if err != nil { return nil, fmt.Errorf("failed to fetch peer %s: %w", id, err) @@ -196,11 +196,11 @@ func (m Manager) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (i return nil, err } - return m.tplHandler.GetPeerConfig(peer) + return m.tplHandler.GetPeerConfig(peer, style) } // GetPeerConfigQrCode returns a QR code image containing the configuration for the given peer. -func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) { +func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) { peer, err := m.wg.GetPeer(ctx, id) if err != nil { return nil, fmt.Errorf("failed to fetch peer %s: %w", id, err) @@ -210,7 +210,7 @@ func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifi return nil, err } - cfgData, err := m.tplHandler.GetPeerConfig(peer) + cfgData, err := m.tplHandler.GetPeerConfig(peer, style) if err != nil { return nil, fmt.Errorf("failed to get peer config for %s: %w", id, err) } diff --git a/internal/app/configfile/template.go b/internal/app/configfile/template.go index ed40d9b..773ab8c 100644 --- a/internal/app/configfile/template.go +++ b/internal/app/configfile/template.go @@ -55,11 +55,12 @@ func (c TemplateHandler) GetInterfaceConfig(cfg *domain.Interface, peers []domai } // GetPeerConfig returns the rendered configuration file for a WireGuard peer. -func (c TemplateHandler) GetPeerConfig(peer *domain.Peer) (io.Reader, error) { +func (c TemplateHandler) GetPeerConfig(peer *domain.Peer, style string) (io.Reader, error) { var tplBuff bytes.Buffer err := c.templates.ExecuteTemplate(&tplBuff, "wg_peer.tpl", map[string]any{ - "Peer": peer, + "Style": style, + "Peer": peer, "Portal": map[string]any{ "Version": "unknown", }, diff --git a/internal/app/configfile/tpl_files/wg_peer.tpl b/internal/app/configfile/tpl_files/wg_peer.tpl index 8a4bae4..211546b 100644 --- a/internal/app/configfile/tpl_files/wg_peer.tpl +++ b/internal/app/configfile/tpl_files/wg_peer.tpl @@ -1,6 +1,8 @@ # AUTOGENERATED FILE - DO NOT EDIT -# This file uses wg-quick format. +# This file uses {{ .Style }} format. +{{- if eq .Style "wgquick"}} # See https://man7.org/linux/man-pages/man8/wg-quick.8.html#CONFIGURATION +{{- end}} # Lines starting with the -WGP- tag are used by # the WireGuard Portal configuration parser. @@ -21,22 +23,27 @@ # Core settings PrivateKey = {{ .Peer.Interface.KeyPair.PrivateKey }} +{{- if eq .Style "wgquick"}} Address = {{ CidrsToString .Peer.Interface.Addresses }} +{{- end}} # Misc. settings (optional) +{{- if eq .Style "wgquick"}} {{- if .Peer.Interface.DnsStr.GetValue}} DNS = {{ .Peer.Interface.DnsStr.GetValue }} {{- if .Peer.Interface.DnsSearchStr.GetValue}}, {{ .Peer.Interface.DnsSearchStr.GetValue }} {{- end}} {{- end}} {{- if ne .Peer.Interface.Mtu.GetValue 0}} MTU = {{ .Peer.Interface.Mtu.GetValue }} {{- end}} -{{- if ne .Peer.Interface.FirewallMark.GetValue 0}} -FwMark = {{ .Peer.Interface.FirewallMark.GetValue }} -{{- end}} {{- if ne .Peer.Interface.RoutingTable.GetValue ""}} Table = {{ .Peer.Interface.RoutingTable.GetValue }} {{- end}} +{{- end}} +{{- if ne .Peer.Interface.FirewallMark.GetValue 0}} +FwMark = {{ .Peer.Interface.FirewallMark.GetValue }} +{{- end}} +{{- if eq .Style "wgquick"}} # Interface hooks (optional) {{- if .Peer.Interface.PreUp.GetValue}} PreUp = {{ .Peer.Interface.PreUp.GetValue }} @@ -50,6 +57,7 @@ PreDown = {{ .Peer.Interface.PreDown.GetValue }} {{- if .Peer.Interface.PostDown.GetValue}} PostDown = {{ .Peer.Interface.PostDown.GetValue }} {{- end}} +{{- end}} [Peer] PublicKey = {{ .Peer.EndpointPublicKey.GetValue }} diff --git a/internal/app/mail/manager.go b/internal/app/mail/manager.go index c7ff431..7b9b3b9 100644 --- a/internal/app/mail/manager.go +++ b/internal/app/mail/manager.go @@ -21,9 +21,9 @@ type ConfigFileManager interface { // GetInterfaceConfig returns the configuration for the given interface. GetInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) (io.Reader, error) // GetPeerConfig returns the configuration for the given peer. - GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) + GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) // GetPeerConfigQrCode returns the QR code for the given peer. - GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) + GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) } type UserDatabaseRepo interface { @@ -89,7 +89,7 @@ func NewMailManager( } // SendPeerEmail sends an email to the user linked to the given peers. -func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error { +func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error { for _, peerId := range peers { peer, err := m.wg.GetPeer(ctx, peerId) if err != nil { @@ -123,7 +123,7 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...doma continue } - err = m.sendPeerEmail(ctx, linkOnly, user, peer) + err = m.sendPeerEmail(ctx, linkOnly, style, user, peer) if err != nil { return fmt.Errorf("failed to send peer email for %s: %w", peerId, err) } @@ -132,7 +132,13 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...doma return nil } -func (m Manager) sendPeerEmail(ctx context.Context, linkOnly bool, user *domain.User, peer *domain.Peer) error { +func (m Manager) sendPeerEmail( + ctx context.Context, + linkOnly bool, + style string, + user *domain.User, + peer *domain.Peer, +) error { qrName := "WireGuardQRCode.png" configName := peer.GetConfigFileName() @@ -148,12 +154,12 @@ func (m Manager) sendPeerEmail(ctx context.Context, linkOnly bool, user *domain. } } else { - peerConfig, err := m.configFiles.GetPeerConfig(ctx, peer.Identifier) + peerConfig, err := m.configFiles.GetPeerConfig(ctx, peer.Identifier, style) if err != nil { return fmt.Errorf("failed to fetch peer config for %s: %w", peer.Identifier, err) } - peerConfigQr, err := m.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier) + peerConfigQr, err := m.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier, style) if err != nil { return fmt.Errorf("failed to fetch peer config QR code for %s: %w", peer.Identifier, err) } diff --git a/internal/domain/base.go b/internal/domain/base.go index 5f549e7..f248ffe 100644 --- a/internal/domain/base.go +++ b/internal/domain/base.go @@ -62,4 +62,7 @@ const ( LockedReasonAdmin = "locked by admin" LockedReasonApi = "locked by admin" + + ConfigStyleRaw = "raw" + ConfigStyleWgQuick = "wgquick" ) From 588bbca14134e836c18fe4be3528341f24603a32 Mon Sep 17 00:00:00 2001 From: h44z Date: Sun, 29 Jun 2025 19:48:46 +0200 Subject: [PATCH 16/69] only execute interface hooks if the state has changed (#469) (#472) --- .../app/wireguard/wireguard_interfaces.go | 51 +++++++------------ 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/internal/app/wireguard/wireguard_interfaces.go b/internal/app/wireguard/wireguard_interfaces.go index f232eec..02f1da1 100644 --- a/internal/app/wireguard/wireguard_interfaces.go +++ b/internal/app/wireguard/wireguard_interfaces.go @@ -461,7 +461,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif physicalInterface, _ := m.wg.GetInterface(ctx, id) - if err := m.handleInterfacePreSaveHooks(true, existingInterface); err != nil { + if err := m.handleInterfacePreSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil { return fmt.Errorf("pre-delete hooks failed: %w", err) } @@ -490,7 +490,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif Table: existingInterface.GetRoutingTable(), }) - if err := m.handleInterfacePostSaveHooks(true, existingInterface); err != nil { + if err := m.handleInterfacePostSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil { return fmt.Errorf("post-delete hooks failed: %w", err) } @@ -509,9 +509,9 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) ( return nil, fmt.Errorf("interface validation failed: %w", err) } - stateChanged := m.hasInterfaceStateChanged(ctx, iface) + oldEnabled, newEnabled := m.getInterfaceStateHistory(ctx, iface) - if err := m.handleInterfacePreSaveHooks(stateChanged, iface); err != nil { + if err := m.handleInterfacePreSaveHooks(iface, oldEnabled, newEnabled); err != nil { return nil, fmt.Errorf("pre-save hooks failed: %w", err) } @@ -551,7 +551,7 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) ( m.bus.Publish(app.TopicRouteUpdate, "interface updated: "+string(iface.Identifier)) } - if err := m.handleInterfacePostSaveHooks(stateChanged, iface); err != nil { + if err := m.handleInterfacePostSaveHooks(iface, oldEnabled, newEnabled); err != nil { return nil, fmt.Errorf("post-save hooks failed: %w", err) } @@ -566,32 +566,13 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) ( return iface, nil } -func (m Manager) hasInterfaceStateChanged(ctx context.Context, iface *domain.Interface) bool { +func (m Manager) getInterfaceStateHistory(ctx context.Context, iface *domain.Interface) (oldEnabled, newEnabled bool) { oldInterface, err := m.db.GetInterface(ctx, iface.Identifier) if err != nil { - return false + return false, !iface.IsDisabled() // if the interface did not exist, we assume it was not enabled } - if oldInterface.IsDisabled() != iface.IsDisabled() { - return true // interface in db has changed - } - - wgInterface, err := m.wg.GetInterface(ctx, iface.Identifier) - if err != nil { - return true // interface might not exist - so we assume that there must be a change - } - - // compare physical interface settings - if len(wgInterface.Addresses) != len(iface.Addresses) || - wgInterface.Mtu != iface.Mtu || - wgInterface.FirewallMark != iface.FirewallMark || - wgInterface.ListenPort != iface.ListenPort || - wgInterface.PrivateKey != iface.PrivateKey || - wgInterface.PublicKey != iface.PublicKey { - return true - } - - return false + return !oldInterface.IsDisabled(), !iface.IsDisabled() } func (m Manager) handleInterfacePreSaveActions(iface *domain.Interface) error { @@ -607,12 +588,14 @@ func (m Manager) handleInterfacePreSaveActions(iface *domain.Interface) error { return nil } -func (m Manager) handleInterfacePreSaveHooks(stateChanged bool, iface *domain.Interface) error { - if !stateChanged { +func (m Manager) handleInterfacePreSaveHooks(iface *domain.Interface, oldEnabled, newEnabled bool) error { + if oldEnabled == newEnabled { return nil // do nothing if state did not change } - if !iface.IsDisabled() { + slog.Debug("executing pre-save hooks", "interface", iface.Identifier, "up", newEnabled) + + if newEnabled { if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PreUp); err != nil { return fmt.Errorf("failed to execute pre-up hook: %w", err) } @@ -624,12 +607,14 @@ func (m Manager) handleInterfacePreSaveHooks(stateChanged bool, iface *domain.In return nil } -func (m Manager) handleInterfacePostSaveHooks(stateChanged bool, iface *domain.Interface) error { - if !stateChanged { +func (m Manager) handleInterfacePostSaveHooks(iface *domain.Interface, oldEnabled, newEnabled bool) error { + if oldEnabled == newEnabled { return nil // do nothing if state did not change } - if !iface.IsDisabled() { + slog.Debug("executing post-save hooks", "interface", iface.Identifier, "up", newEnabled) + + if newEnabled { if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PostUp); err != nil { return fmt.Errorf("failed to execute post-up hook: %w", err) } From edb88b5768d25a4cce1d352a05ea6d02f606c19f Mon Sep 17 00:00:00 2001 From: h44z Date: Sun, 29 Jun 2025 19:49:01 +0200 Subject: [PATCH 17/69] new webhook models (#444) (#471) warning: existing webhook receivers need to be adapted to the new models --- docs/documentation/configuration/overview.md | 13 - docs/documentation/usage/webhooks.md | 237 +++++++++++++++++-- internal/app/webhooks/manager.go | 43 ++-- internal/app/webhooks/model.go | 7 +- internal/app/webhooks/models/interface.go | 99 ++++++++ internal/app/webhooks/models/peer.go | 89 +++++++ internal/app/webhooks/models/peer_metrics.go | 50 ++++ internal/app/webhooks/models/user.go | 56 +++++ internal/app/wireguard/statistics.go | 10 +- 9 files changed, 546 insertions(+), 58 deletions(-) create mode 100644 internal/app/webhooks/models/interface.go create mode 100644 internal/app/webhooks/models/peer.go create mode 100644 internal/app/webhooks/models/peer_metrics.go create mode 100644 internal/app/webhooks/models/user.go diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 3611171..dd20d79 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -673,19 +673,6 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio ## Webhook The webhook section allows you to configure a webhook that is called on certain events in WireGuard Portal. -A JSON object is sent in a POST request to the webhook URL with the following structure: -```json -{ - "event": "update", - "entity": "peer", - "identifier": "the-peer-identifier", - "payload": { - // The payload of the event, e.g. peer data. - // Check the API documentation for the exact structure. - } -} -``` - Further details can be found in the [usage documentation](../usage/webhooks.md). ### `url` diff --git a/docs/documentation/usage/webhooks.md b/docs/documentation/usage/webhooks.md index 7ec8cbf..1d0c692 100644 --- a/docs/documentation/usage/webhooks.md +++ b/docs/documentation/usage/webhooks.md @@ -38,11 +38,12 @@ WireGuard Portal supports various events that can trigger webhooks. The followin - `connect`: Triggered when a user connects to the VPN. - `disconnect`: Triggered when a user disconnects from the VPN. -The following entity types can trigger webhooks: +The following entity models are supported for webhook events: -- `user`: When a WireGuard Portal user is created, updated, or deleted. -- `peer`: When a peer is created, updated, or deleted. This entity can also trigger `connect` and `disconnect` events. -- `interface`: When a device is created, updated, or deleted. +- `user`: WireGuard Portal users support creation, update, or deletion events. +- `peer`: Peers support creation, update, or deletion events. Via the `peer_metric` entity, you can also receive connection status updates. +- `peer_metric`: Peer metrics support connection status updates, such as when a peer connects or disconnects. +- `interface`: WireGuard interfaces support creation, update, or deletion events. ## Payload Structure @@ -51,36 +52,234 @@ A common shell structure for webhook payloads is as follows: ```json { - "event": "create", - "entity": "user", - "identifier": "the-user-identifier", + "event": "create", // The event type, e.g. "create", "update", "delete", "connect", "disconnect" + "entity": "user", // The entity type, e.g. "user", "peer", "peer_metric", "interface" + "identifier": "the-user-identifier", // Unique identifier of the entity, e.g. user ID or peer ID "payload": { - // The payload of the event, e.g. peer data. - // Check the API documentation for the exact structure. + // The payload of the event, e.g. a Peer model. + // Detailed model descriptions are provided below. } } ``` +### Payload Models -### Example Payload +All payload models are encoded as JSON objects. Fields with empty values might be omitted in the payload. + +#### User Payload (entity: `user`) + +| JSON Field | Type | Description | +|----------------|-------------|-----------------------------------| +| CreatedBy | string | Creator identifier | +| UpdatedBy | string | Last updater identifier | +| CreatedAt | time.Time | Time of creation | +| UpdatedAt | time.Time | Time of last update | +| Identifier | string | Unique user identifier | +| Email | string | User email | +| Source | string | Authentication source | +| ProviderName | string | Name of auth provider | +| IsAdmin | bool | Whether user has admin privileges | +| Firstname | string | User's first name (optional) | +| Lastname | string | User's last name (optional) | +| Phone | string | Contact phone number (optional) | +| Department | string | User's department (optional) | +| Notes | string | Additional notes (optional) | +| Disabled | *time.Time | When user was disabled | +| DisabledReason | string | Reason for deactivation | +| Locked | *time.Time | When user account was locked | +| LockedReason | string | Reason for being locked | + + +#### Peer Payload (entity: `peer`) + +| JSON Field | Type | Description | +|----------------------|------------|----------------------------------------| +| CreatedBy | string | Creator identifier | +| UpdatedBy | string | Last updater identifier | +| CreatedAt | time.Time | Creation timestamp | +| UpdatedAt | time.Time | Last update timestamp | +| Endpoint | string | Peer endpoint address | +| EndpointPublicKey | string | Public key of peer endpoint | +| AllowedIPsStr | string | Allowed IPs | +| ExtraAllowedIPsStr | string | Extra allowed IPs | +| PresharedKey | string | Pre-shared key for encryption | +| PersistentKeepalive | int | Keepalive interval in seconds | +| DisplayName | string | Display name of the peer | +| Identifier | string | Unique identifier | +| UserIdentifier | string | Associated user ID (optional) | +| InterfaceIdentifier | string | Interface this peer is attached to | +| Disabled | *time.Time | When the peer was disabled | +| DisabledReason | string | Reason for being disabled | +| ExpiresAt | *time.Time | Expiration date | +| Notes | string | Notes for this peer | +| AutomaticallyCreated | bool | Whether peer was auto-generated | +| PrivateKey | string | Peer private key | +| PublicKey | string | Peer public key | +| InterfaceType | string | Type of the peer interface | +| Addresses | []string | IP addresses | +| CheckAliveAddress | string | Address used for alive checks | +| DnsStr | string | DNS servers | +| DnsSearchStr | string | DNS search domains | +| Mtu | int | MTU (Maximum Transmission Unit) | +| FirewallMark | uint32 | Firewall mark (optional) | +| RoutingTable | string | Custom routing table (optional) | +| PreUp | string | Command before bringing up interface | +| PostUp | string | Command after bringing up interface | +| PreDown | string | Command before bringing down interface | +| PostDown | string | Command after bringing down interface | + + +#### Interface Payload (entity: `interface`) + +| JSON Field | Type | Description | +|----------------------------|------------|----------------------------------------| +| CreatedBy | string | Creator identifier | +| UpdatedBy | string | Last updater identifier | +| CreatedAt | time.Time | Creation timestamp | +| UpdatedAt | time.Time | Last update timestamp | +| Identifier | string | Unique identifier | +| PrivateKey | string | Private key for the interface | +| PublicKey | string | Public key for the interface | +| ListenPort | int | Listening port | +| Addresses | []string | IP addresses | +| DnsStr | string | DNS servers | +| DnsSearchStr | string | DNS search domains | +| Mtu | int | MTU (Maximum Transmission Unit) | +| FirewallMark | uint32 | Firewall mark | +| RoutingTable | string | Custom routing table | +| PreUp | string | Command before bringing up interface | +| PostUp | string | Command after bringing up interface | +| PreDown | string | Command before bringing down interface | +| PostDown | string | Command after bringing down interface | +| SaveConfig | bool | Whether to save config to file | +| DisplayName | string | Human-readable name | +| Type | string | Type of interface | +| DriverType | string | Driver used | +| Disabled | *time.Time | When the interface was disabled | +| DisabledReason | string | Reason for being disabled | +| PeerDefNetworkStr | string | Default peer network configuration | +| PeerDefDnsStr | string | Default peer DNS servers | +| PeerDefDnsSearchStr | string | Default peer DNS search domains | +| PeerDefEndpoint | string | Default peer endpoint | +| PeerDefAllowedIPsStr | string | Default peer allowed IPs | +| PeerDefMtu | int | Default peer MTU | +| PeerDefPersistentKeepalive | int | Default keepalive value | +| PeerDefFirewallMark | uint32 | Default firewall mark for peers | +| PeerDefRoutingTable | string | Default routing table for peers | +| PeerDefPreUp | string | Default peer pre-up command | +| PeerDefPostUp | string | Default peer post-up command | +| PeerDefPreDown | string | Default peer pre-down command | +| PeerDefPostDown | string | Default peer post-down command | + + +#### Peer Metrics Payload (entity: `peer_metric`) + +| JSON Field | Type | Description | +|------------|------------|----------------------------| +| Status | PeerStatus | Current status of the peer | +| Peer | Peer | Peer data | + +`PeerStatus` sub-structure: + +| JSON Field | Type | Description | +|------------------|------------|------------------------------| +| UpdatedAt | time.Time | Time of last status update | +| IsConnected | bool | Is peer currently connected | +| IsPingable | bool | Can peer be pinged | +| LastPing | *time.Time | Time of last successful ping | +| BytesReceived | uint64 | Bytes received from peer | +| BytesTransmitted | uint64 | Bytes sent to peer | +| Endpoint | string | Last known endpoint | +| LastHandshake | *time.Time | Last successful handshake | +| LastSessionStart | *time.Time | Time the last session began | + + +### Example Payloads The following payload is an example of a webhook event when a peer connects to the VPN: ```json { "event": "connect", + "entity": "peer_metric", + "identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=", + "payload": { + "Status": { + "UpdatedAt": "2025-06-27T22:20:08.734900034+02:00", + "IsConnected": true, + "IsPingable": false, + "BytesReceived": 212, + "BytesTransmitted": 2884, + "Endpoint": "10.55.66.77:58756", + "LastHandshake": "2025-06-27T22:19:46.580842776+02:00", + "LastSessionStart": "2025-06-27T22:19:46.580842776+02:00" + }, + "Peer": { + "CreatedBy": "admin@wgportal.local", + "UpdatedBy": "admin@wgportal.local", + "CreatedAt": "2025-06-26T21:43:49.251839574+02:00", + "UpdatedAt": "2025-06-27T22:18:39.67763985+02:00", + "Endpoint": "10.55.66.1:51820", + "EndpointPublicKey": "eiVibpi3C2PUPcx2kwA5s09OgHx7AEaKMd33k0LQ5mM=", + "AllowedIPsStr": "10.11.12.0/24,fdfd:d3ad:c0de:1234::/64", + "ExtraAllowedIPsStr": "", + "PresharedKey": "p9DDeLUSLOdQcjS8ZsBAiqUzwDIUvTyzavRZFuzhvyE=", + "PersistentKeepalive": 16, + "DisplayName": "Peer Fb5TaziA", + "Identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=", + "UserIdentifier": "admin@wgportal.local", + "InterfaceIdentifier": "wgTesting", + "AutomaticallyCreated": false, + "PrivateKey": "QBFNBe+7J49ergH0ze2TGUJMFrL/2bOL50Z2cgluYW8=", + "PublicKey": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=", + "InterfaceType": "client", + "Addresses": [ + "10.11.12.10/32", + "fdfd:d3ad:c0de:1234::a/128" + ], + "CheckAliveAddress": "", + "DnsStr": "", + "DnsSearchStr": "", + "Mtu": 1420 + } + } +} +``` + +Here is another example of a webhook event when a peer is updated: + +```json +{ + "event": "update", "entity": "peer", "identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=", "payload": { - "PeerId": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=", - "IsConnected": true, - "IsPingable": false, - "LastPing": null, - "BytesReceived": 1860, - "BytesTransmitted": 10824, - "LastHandshake": "2025-06-26T23:04:33.325216659+02:00", - "Endpoint": "10.55.66.77:33874", - "LastSessionStart": "2025-06-26T22:50:40.10221606+02:00" + "CreatedBy": "admin@wgportal.local", + "UpdatedBy": "admin@wgportal.local", + "CreatedAt": "2025-06-26T21:43:49.251839574+02:00", + "UpdatedAt": "2025-06-27T22:18:39.67763985+02:00", + "Endpoint": "10.55.66.1:51820", + "EndpointPublicKey": "eiVibpi3C2PUPcx2kwA5s09OgHx7AEaKMd33k0LQ5mM=", + "AllowedIPsStr": "10.11.12.0/24,fdfd:d3ad:c0de:1234::/64", + "ExtraAllowedIPsStr": "", + "PresharedKey": "p9DDeLUSLOdQcjS8ZsBAiqUzwDIUvTyzavRZFuzhvyE=", + "PersistentKeepalive": 16, + "DisplayName": "Peer Fb5TaziA", + "Identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=", + "UserIdentifier": "admin@wgportal.local", + "InterfaceIdentifier": "wgTesting", + "AutomaticallyCreated": false, + "PrivateKey": "QBFNBe+7J49ergH0ze2TGUJMFrL/2bOL50Z2cgluYW8=", + "PublicKey": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=", + "InterfaceType": "client", + "Addresses": [ + "10.11.12.10/32", + "fdfd:d3ad:c0de:1234::a/128" + ], + "CheckAliveAddress": "", + "DnsStr": "", + "DnsSearchStr": "", + "Mtu": 1420 } } ``` \ No newline at end of file diff --git a/internal/app/webhooks/manager.go b/internal/app/webhooks/manager.go index 702ac6c..b8d010c 100644 --- a/internal/app/webhooks/manager.go +++ b/internal/app/webhooks/manager.go @@ -8,6 +8,7 @@ import ( "net/http" "github.com/h44z/wg-portal/internal/app" + "github.com/h44z/wg-portal/internal/app/webhooks/models" "github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/domain" ) @@ -101,46 +102,46 @@ func (m Manager) sendWebhook(ctx context.Context, data io.Reader) error { } func (m Manager) handleUserCreateEvent(user domain.User) { - m.handleGenericEvent(WebhookEventCreate, user) + m.handleGenericEvent(WebhookEventCreate, models.NewUser(user)) } func (m Manager) handleUserUpdateEvent(user domain.User) { - m.handleGenericEvent(WebhookEventUpdate, user) + m.handleGenericEvent(WebhookEventUpdate, models.NewUser(user)) } func (m Manager) handleUserDeleteEvent(user domain.User) { - m.handleGenericEvent(WebhookEventDelete, user) + m.handleGenericEvent(WebhookEventDelete, models.NewUser(user)) } func (m Manager) handlePeerCreateEvent(peer domain.Peer) { - m.handleGenericEvent(WebhookEventCreate, peer) + m.handleGenericEvent(WebhookEventCreate, models.NewPeer(peer)) } func (m Manager) handlePeerUpdateEvent(peer domain.Peer) { - m.handleGenericEvent(WebhookEventUpdate, peer) + m.handleGenericEvent(WebhookEventUpdate, models.NewPeer(peer)) } func (m Manager) handlePeerDeleteEvent(peer domain.Peer) { - m.handleGenericEvent(WebhookEventDelete, peer) + m.handleGenericEvent(WebhookEventDelete, models.NewPeer(peer)) } func (m Manager) handleInterfaceCreateEvent(iface domain.Interface) { - m.handleGenericEvent(WebhookEventCreate, iface) + m.handleGenericEvent(WebhookEventCreate, models.NewInterface(iface)) } func (m Manager) handleInterfaceUpdateEvent(iface domain.Interface) { - m.handleGenericEvent(WebhookEventUpdate, iface) + m.handleGenericEvent(WebhookEventUpdate, models.NewInterface(iface)) } func (m Manager) handleInterfaceDeleteEvent(iface domain.Interface) { - m.handleGenericEvent(WebhookEventDelete, iface) + m.handleGenericEvent(WebhookEventDelete, models.NewInterface(iface)) } -func (m Manager) handlePeerStateChangeEvent(peerStatus domain.PeerStatus) { +func (m Manager) handlePeerStateChangeEvent(peerStatus domain.PeerStatus, peer domain.Peer) { if peerStatus.IsConnected { - m.handleGenericEvent(WebhookEventConnect, peerStatus) + m.handleGenericEvent(WebhookEventConnect, models.NewPeerMetrics(peerStatus, peer)) } else { - m.handleGenericEvent(WebhookEventDisconnect, peerStatus) + m.handleGenericEvent(WebhookEventDisconnect, models.NewPeerMetrics(peerStatus, peer)) } } @@ -177,18 +178,18 @@ func (m Manager) createWebhookData(action WebhookEvent, payload any) (*WebhookDa } switch v := payload.(type) { - case domain.User: + case models.User: d.Entity = WebhookEntityUser - d.Identifier = string(v.Identifier) - case domain.Peer: + d.Identifier = v.Identifier + case models.Peer: d.Entity = WebhookEntityPeer - d.Identifier = string(v.Identifier) - case domain.Interface: + d.Identifier = v.Identifier + case models.Interface: d.Entity = WebhookEntityInterface - d.Identifier = string(v.Identifier) - case domain.PeerStatus: - d.Entity = WebhookEntityPeer - d.Identifier = string(v.PeerId) + d.Identifier = v.Identifier + case models.PeerMetrics: + d.Entity = WebhookEntityPeerMetric + d.Identifier = v.Peer.Identifier default: return nil, fmt.Errorf("unsupported payload type: %T", v) } diff --git a/internal/app/webhooks/model.go b/internal/app/webhooks/model.go index b0ca9a5..c806d47 100644 --- a/internal/app/webhooks/model.go +++ b/internal/app/webhooks/model.go @@ -34,9 +34,10 @@ func (d *WebhookData) Serialize() (io.Reader, error) { type WebhookEntity = string const ( - WebhookEntityUser WebhookEntity = "user" - WebhookEntityPeer WebhookEntity = "peer" - WebhookEntityInterface WebhookEntity = "interface" + WebhookEntityUser WebhookEntity = "user" + WebhookEntityPeer WebhookEntity = "peer" + WebhookEntityPeerMetric WebhookEntity = "peer_metric" + WebhookEntityInterface WebhookEntity = "interface" ) type WebhookEvent = string diff --git a/internal/app/webhooks/models/interface.go b/internal/app/webhooks/models/interface.go new file mode 100644 index 0000000..c3a0eea --- /dev/null +++ b/internal/app/webhooks/models/interface.go @@ -0,0 +1,99 @@ +package models + +import ( + "time" + + "github.com/h44z/wg-portal/internal/domain" +) + +// Interface represents an interface model for webhooks. For details about the fields, see the domain.Interface struct. +type Interface struct { + CreatedBy string `json:"CreatedBy"` + UpdatedBy string `json:"UpdatedBy"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + + Identifier string `json:"Identifier"` + PrivateKey string `json:"PrivateKey"` + PublicKey string `json:"PublicKey"` + ListenPort int `json:"ListenPort"` + + Addresses []string `json:"Addresses"` + DnsStr string `json:"DnsStr"` + DnsSearchStr string `json:"DnsSearchStr"` + + Mtu int `json:"Mtu"` + FirewallMark uint32 `json:"FirewallMark"` + RoutingTable string `json:"RoutingTable"` + + PreUp string `json:"PreUp"` + PostUp string `json:"PostUp"` + PreDown string `json:"PreDown"` + PostDown string `json:"PostDown"` + + SaveConfig bool `json:"SaveConfig"` + + DisplayName string `json:"DisplayName"` + Type string `json:"Type"` + DriverType string `json:"DriverType"` + Disabled *time.Time `json:"Disabled,omitempty"` + DisabledReason string `json:"DisabledReason,omitempty"` + + PeerDefNetworkStr string `json:"PeerDefNetworkStr,omitempty"` + PeerDefDnsStr string `json:"PeerDefDnsStr,omitempty"` + PeerDefDnsSearchStr string `json:"PeerDefDnsSearchStr,omitempty"` + PeerDefEndpoint string `json:"PeerDefEndpoint,omitempty"` + PeerDefAllowedIPsStr string `json:"PeerDefAllowedIPsStr,omitempty"` + PeerDefMtu int `json:"PeerDefMtu,omitempty"` + PeerDefPersistentKeepalive int `json:"PeerDefPersistentKeepalive,omitempty"` + PeerDefFirewallMark uint32 `json:"PeerDefFirewallMark,omitempty"` + PeerDefRoutingTable string `json:"PeerDefRoutingTable,omitempty"` + + PeerDefPreUp string `json:"PeerDefPreUp,omitempty"` + PeerDefPostUp string `json:"PeerDefPostUp,omitempty"` + PeerDefPreDown string `json:"PeerDefPreDown,omitempty"` + PeerDefPostDown string `json:"PeerDefPostDown,omitempty"` +} + +// NewInterface creates a new Interface model from a domain.Interface. +func NewInterface(src domain.Interface) Interface { + return Interface{ + CreatedBy: src.CreatedBy, + UpdatedBy: src.UpdatedBy, + CreatedAt: src.CreatedAt, + UpdatedAt: src.UpdatedAt, + Identifier: string(src.Identifier), + PrivateKey: src.KeyPair.PrivateKey, + PublicKey: src.KeyPair.PublicKey, + ListenPort: src.ListenPort, + Addresses: domain.CidrsToStringSlice(src.Addresses), + DnsStr: src.DnsStr, + DnsSearchStr: src.DnsSearchStr, + Mtu: src.Mtu, + FirewallMark: src.FirewallMark, + RoutingTable: src.RoutingTable, + PreUp: src.PreUp, + PostUp: src.PostUp, + PreDown: src.PreDown, + PostDown: src.PostDown, + SaveConfig: src.SaveConfig, + DisplayName: string(src.Identifier), + Type: string(src.Type), + DriverType: src.DriverType, + Disabled: src.Disabled, + DisabledReason: src.DisabledReason, + PeerDefNetworkStr: src.PeerDefNetworkStr, + PeerDefDnsStr: src.PeerDefDnsStr, + PeerDefDnsSearchStr: src.PeerDefDnsSearchStr, + PeerDefEndpoint: src.PeerDefEndpoint, + PeerDefAllowedIPsStr: src.PeerDefAllowedIPsStr, + PeerDefMtu: src.PeerDefMtu, + PeerDefPersistentKeepalive: src.PeerDefPersistentKeepalive, + PeerDefFirewallMark: src.PeerDefFirewallMark, + PeerDefRoutingTable: src.PeerDefRoutingTable, + PeerDefPreUp: src.PeerDefPreUp, + PeerDefPostUp: src.PeerDefPostUp, + PeerDefPreDown: src.PeerDefPreDown, + PeerDefPostDown: src.PeerDefPostDown, + } +} diff --git a/internal/app/webhooks/models/peer.go b/internal/app/webhooks/models/peer.go new file mode 100644 index 0000000..c7a5919 --- /dev/null +++ b/internal/app/webhooks/models/peer.go @@ -0,0 +1,89 @@ +package models + +import ( + "time" + + "github.com/h44z/wg-portal/internal/domain" +) + +// Peer represents a peer model for webhooks. For details about the fields, see the domain.Peer struct. +type Peer struct { + CreatedBy string `json:"CreatedBy"` + UpdatedBy string `json:"UpdatedBy"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + + Endpoint string `json:"Endpoint"` + EndpointPublicKey string `json:"EndpointPublicKey"` + AllowedIPsStr string `json:"AllowedIPsStr"` + ExtraAllowedIPsStr string `json:"ExtraAllowedIPsStr"` + PresharedKey string `json:"PresharedKey"` + PersistentKeepalive int `json:"PersistentKeepalive"` + + DisplayName string `json:"DisplayName"` + Identifier string `json:"Identifier"` + UserIdentifier string `json:"UserIdentifier"` + InterfaceIdentifier string `json:"InterfaceIdentifier"` + Disabled *time.Time `json:"Disabled,omitempty"` + DisabledReason string `json:"DisabledReason,omitempty"` + ExpiresAt *time.Time `json:"ExpiresAt,omitempty"` + Notes string `json:"Notes,omitempty"` + AutomaticallyCreated bool `json:"AutomaticallyCreated"` + + PrivateKey string `json:"PrivateKey"` + PublicKey string `json:"PublicKey"` + + InterfaceType string `json:"InterfaceType"` + + Addresses []string `json:"Addresses"` + CheckAliveAddress string `json:"CheckAliveAddress"` + DnsStr string `json:"DnsStr"` + DnsSearchStr string `json:"DnsSearchStr"` + Mtu int `json:"Mtu"` + FirewallMark uint32 `json:"FirewallMark,omitempty"` + RoutingTable string `json:"RoutingTable,omitempty"` + + PreUp string `json:"PreUp,omitempty"` + PostUp string `json:"PostUp,omitempty"` + PreDown string `json:"PreDown,omitempty"` + PostDown string `json:"PostDown,omitempty"` +} + +// NewPeer creates a new Peer model from a domain.Peer. +func NewPeer(src domain.Peer) Peer { + return Peer{ + CreatedBy: src.CreatedBy, + UpdatedBy: src.UpdatedBy, + CreatedAt: src.CreatedAt, + UpdatedAt: src.UpdatedAt, + Endpoint: src.Endpoint.GetValue(), + EndpointPublicKey: src.EndpointPublicKey.GetValue(), + AllowedIPsStr: src.AllowedIPsStr.GetValue(), + ExtraAllowedIPsStr: src.ExtraAllowedIPsStr, + PresharedKey: string(src.PresharedKey), + PersistentKeepalive: src.PersistentKeepalive.GetValue(), + DisplayName: src.DisplayName, + Identifier: string(src.Identifier), + UserIdentifier: string(src.UserIdentifier), + InterfaceIdentifier: string(src.InterfaceIdentifier), + Disabled: src.Disabled, + DisabledReason: src.DisabledReason, + ExpiresAt: src.ExpiresAt, + Notes: src.Notes, + AutomaticallyCreated: src.AutomaticallyCreated, + PrivateKey: src.Interface.KeyPair.PrivateKey, + PublicKey: src.Interface.KeyPair.PublicKey, + InterfaceType: string(src.Interface.Type), + Addresses: domain.CidrsToStringSlice(src.Interface.Addresses), + CheckAliveAddress: src.Interface.CheckAliveAddress, + DnsStr: src.Interface.DnsStr.GetValue(), + DnsSearchStr: src.Interface.DnsSearchStr.GetValue(), + Mtu: src.Interface.Mtu.GetValue(), + FirewallMark: src.Interface.FirewallMark.GetValue(), + RoutingTable: src.Interface.RoutingTable.GetValue(), + PreUp: src.Interface.PreUp.GetValue(), + PostUp: src.Interface.PostUp.GetValue(), + PreDown: src.Interface.PreDown.GetValue(), + PostDown: src.Interface.PostDown.GetValue(), + } +} diff --git a/internal/app/webhooks/models/peer_metrics.go b/internal/app/webhooks/models/peer_metrics.go new file mode 100644 index 0000000..f380d2c --- /dev/null +++ b/internal/app/webhooks/models/peer_metrics.go @@ -0,0 +1,50 @@ +package models + +import ( + "time" + + "github.com/h44z/wg-portal/internal/domain" +) + +// PeerMetrics represents a peer metrics model for webhooks. +// For details about the fields, see the domain.PeerStatus and domain.Peer structs. +type PeerMetrics struct { + Status PeerStatus `json:"Status"` + Peer Peer `json:"Peer"` +} + +// PeerStatus represents the status of a peer for webhooks. +// For details about the fields, see the domain.PeerStatus struct. +type PeerStatus struct { + UpdatedAt time.Time `json:"UpdatedAt"` + + IsConnected bool `json:"IsConnected"` + + IsPingable bool `json:"IsPingable"` + LastPing *time.Time `json:"LastPing,omitempty"` + + BytesReceived uint64 `json:"BytesReceived"` + BytesTransmitted uint64 `json:"BytesTransmitted"` + + Endpoint string `json:"Endpoint"` + LastHandshake *time.Time `json:"LastHandshake,omitempty"` + LastSessionStart *time.Time `json:"LastSessionStart,omitempty"` +} + +// NewPeerMetrics creates a new PeerMetrics model from the domain.PeerStatus and domain.Peer models. +func NewPeerMetrics(status domain.PeerStatus, peer domain.Peer) PeerMetrics { + return PeerMetrics{ + Status: PeerStatus{ + UpdatedAt: status.UpdatedAt, + IsConnected: status.IsConnected, + IsPingable: status.IsPingable, + LastPing: status.LastPing, + BytesReceived: status.BytesReceived, + BytesTransmitted: status.BytesTransmitted, + Endpoint: status.Endpoint, + LastHandshake: status.LastHandshake, + LastSessionStart: status.LastSessionStart, + }, + Peer: NewPeer(peer), + } +} diff --git a/internal/app/webhooks/models/user.go b/internal/app/webhooks/models/user.go new file mode 100644 index 0000000..defe962 --- /dev/null +++ b/internal/app/webhooks/models/user.go @@ -0,0 +1,56 @@ +package models + +import ( + "time" + + "github.com/h44z/wg-portal/internal/domain" +) + +// User represents a user model for webhooks. For details about the fields, see the domain.User struct. +type User struct { + CreatedBy string `json:"CreatedBy"` + UpdatedBy string `json:"UpdatedBy"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + + Identifier string `json:"Identifier"` + Email string `json:"Email"` + Source string `json:"Source"` + ProviderName string `json:"ProviderName"` + IsAdmin bool `json:"IsAdmin"` + + Firstname string `json:"Firstname,omitempty"` + Lastname string `json:"Lastname,omitempty"` + Phone string `json:"Phone,omitempty"` + Department string `json:"Department,omitempty"` + Notes string `json:"Notes,omitempty"` + + Disabled *time.Time `json:"Disabled,omitempty"` + DisabledReason string `json:"DisabledReason,omitempty"` + Locked *time.Time `json:"Locked,omitempty"` + LockedReason string `json:"LockedReason,omitempty"` +} + +// NewUser creates a new User model from a domain.User +func NewUser(src domain.User) User { + return User{ + CreatedBy: src.CreatedBy, + UpdatedBy: src.UpdatedBy, + CreatedAt: src.CreatedAt, + UpdatedAt: src.UpdatedAt, + Identifier: string(src.Identifier), + Email: src.Email, + Source: string(src.Source), + ProviderName: src.ProviderName, + IsAdmin: src.IsAdmin, + Firstname: src.Firstname, + Lastname: src.Lastname, + Phone: src.Phone, + Department: src.Department, + Notes: src.Notes, + Disabled: src.Disabled, + DisabledReason: src.DisabledReason, + Locked: src.Locked, + LockedReason: src.LockedReason, + } +} diff --git a/internal/app/wireguard/statistics.go b/internal/app/wireguard/statistics.go index 2733468..dcc8b36 100644 --- a/internal/app/wireguard/statistics.go +++ b/internal/app/wireguard/statistics.go @@ -213,8 +213,14 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) { } if connectionStateChanged { + peerModel, err := c.db.GetPeer(ctx, peer.Identifier) + if err != nil { + slog.Error("failed to fetch peer for data collection", "peer", peer.Identifier, "error", + err) + continue + } // publish event if connection state changed - c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus) + c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus, *peerModel) } } } @@ -356,7 +362,7 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) { if connectionStateChanged { // publish event if connection state changed - c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus) + c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus, peer) } } } From f2868409641fc0ce28ac140c7f0fb94d10d61d0a Mon Sep 17 00:00:00 2001 From: h44z Date: Sun, 29 Jun 2025 20:00:15 +0200 Subject: [PATCH 18/69] fix oauth domain check (#474) (#476) --- internal/app/auth/auth.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/app/auth/auth.go b/internal/app/auth/auth.go index 380fe81..0ff8d9e 100644 --- a/internal/app/auth/auth.go +++ b/internal/app/auth/auth.go @@ -434,6 +434,10 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, return nil, fmt.Errorf("unable to parse user information: %w", err) } + if !isDomainAllowed(userInfo.Email, oauthProvider.GetAllowedDomains()) { + return nil, fmt.Errorf("user %s is not in allowed domains", userInfo.Email) + } + ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo()) // switch to admin user context to check if user exists user, err := a.processUserInfo(ctx, userInfo, domain.UserSourceOauth, oauthProvider.GetName(), @@ -450,10 +454,6 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, return nil, fmt.Errorf("unable to process user information: %w", err) } - if !isDomainAllowed(userInfo.Email, oauthProvider.GetAllowedDomains()) { - return nil, fmt.Errorf("user is not in allowed domains: %w", err) - } - if user.IsLocked() || user.IsDisabled() { a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{ Ctx: ctx, From a7bd3b3f9556dfee06b7663a7e2d78328bc245eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:20:23 +0200 Subject: [PATCH 19/69] chore(deps): bump github.com/go-playground/validator/v10 (#480) Bumps [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) from 10.26.0 to 10.27.0. - [Release notes](https://github.com/go-playground/validator/releases) - [Commits](https://github.com/go-playground/validator/compare/v10.26.0...v10.27.0) --- updated-dependencies: - dependency-name: github.com/go-playground/validator/v10 dependency-version: 10.27.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1f2e60c..680a605 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/glebarez/sqlite v1.11.0 github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-pkgz/routegroup v1.4.1 - github.com/go-playground/validator/v10 v10.26.0 + github.com/go-playground/validator/v10 v10.27.0 github.com/go-webauthn/webauthn v0.13.0 github.com/google/uuid v1.6.0 github.com/prometheus-community/pro-bing v0.7.0 diff --git a/go.sum b/go.sum index 4b64484..1f35462 100644 --- a/go.sum +++ b/go.sum @@ -70,8 +70,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= -github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= From a6d985d3ce97dddc71ebdf9d3f42c03a127571b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:36:11 +0200 Subject: [PATCH 20/69] chore(deps): bump the patch group across 1 directory with 4 updates (#485) Bumps the patch group with 2 updates in the / directory: [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) and [github.com/swaggo/swag](https://github.com/swaggo/swag). Updates `github.com/go-webauthn/webauthn` from 0.13.0 to 0.13.4 - [Release notes](https://github.com/go-webauthn/webauthn/releases) - [Commits](https://github.com/go-webauthn/webauthn/compare/v0.13.0...v0.13.4) Updates `github.com/swaggo/swag` from 1.16.4 to 1.16.5 - [Release notes](https://github.com/swaggo/swag/releases) - [Changelog](https://github.com/swaggo/swag/blob/master/.goreleaser.yml) - [Commits](https://github.com/swaggo/swag/compare/v1.16.4...v1.16.5) Updates `golang.org/x/crypto` from 0.39.0 to 0.40.0 - [Commits](https://github.com/golang/crypto/compare/v0.39.0...v0.40.0) Updates `golang.org/x/sys` from 0.33.0 to 0.34.0 - [Commits](https://github.com/golang/sys/compare/v0.33.0...v0.34.0) --- updated-dependencies: - dependency-name: github.com/go-webauthn/webauthn dependency-version: 0.13.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch - dependency-name: github.com/swaggo/swag dependency-version: 1.16.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch - dependency-name: golang.org/x/crypto dependency-version: 0.40.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: patch - dependency-name: golang.org/x/sys dependency-version: 0.34.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 23 ++++++++++++----------- go.sum | 44 ++++++++++++++++++++++---------------------- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/go.mod b/go.mod index 680a605..fa7499f 100644 --- a/go.mod +++ b/go.mod @@ -10,20 +10,20 @@ require ( github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-pkgz/routegroup v1.4.1 github.com/go-playground/validator/v10 v10.27.0 - github.com/go-webauthn/webauthn v0.13.0 + github.com/go-webauthn/webauthn v0.13.4 github.com/google/uuid v1.6.0 github.com/prometheus-community/pro-bing v0.7.0 github.com/prometheus/client_golang v1.22.0 github.com/stretchr/testify v1.10.0 - github.com/swaggo/swag v1.16.4 + github.com/swaggo/swag v1.16.5 github.com/vardius/message-bus v1.1.5 github.com/vishvananda/netlink v1.3.1 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.39.0 + golang.org/x/crypto v0.40.0 golang.org/x/oauth2 v0.30.0 - golang.org/x/sys v0.33.0 + golang.org/x/sys v0.34.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 @@ -40,7 +40,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fxamacker/cbor/v2 v2.8.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect @@ -53,8 +53,8 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.9.2 // indirect github.com/go-test/deep v1.1.1 // indirect - github.com/go-webauthn/x v0.1.21 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/go-webauthn/x v0.1.23 // indirect + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -87,10 +87,11 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/net v0.40.0 // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/text v0.26.0 // indirect - golang.org/x/tools v0.33.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.34.0 // indirect golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect google.golang.org/protobuf v1.36.6 // indirect modernc.org/libc v1.63.0 // indirect diff --git a/go.sum b/go.sum index 1f35462..9b9a87b 100644 --- a/go.sum +++ b/go.sum @@ -40,8 +40,8 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/ github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= -github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= @@ -76,15 +76,15 @@ github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRj github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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-webauthn/webauthn v0.13.0 h1:cJIL1/1l+22UekVhipziAaSgESJxokYkowUqAIsWs0Y= -github.com/go-webauthn/webauthn v0.13.0/go.mod h1:Oy9o2o79dbLKRPZWWgRIOdtBGAhKnDIaBp2PFkICRHs= -github.com/go-webauthn/x v0.1.21 h1:nFbckQxudvHEJn2uy1VEi713MeSpApoAv9eRqsb9AdQ= -github.com/go-webauthn/x v0.1.21/go.mod h1:sEYohtg1zL4An1TXIUIQ5csdmoO+WO0R4R2pGKaHYKA= +github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ= +github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI= +github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI= +github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E= github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= @@ -197,8 +197,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= -github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= +github.com/swaggo/swag v1.16.5 h1:nMf2fEV1TetMTJb4XzD0Lz7jFfKJmJKGTygEey8NSxM= +github.com/swaggo/swag v1.16.5/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA= github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= @@ -228,8 +228,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -252,8 +252,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -263,8 +263,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.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -285,8 +285,8 @@ golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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= @@ -305,16 +305,16 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 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.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 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= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= From 1794b8653a99124e3cb3303360ab1c9699409eca Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Sat, 19 Jul 2025 23:29:05 +0200 Subject: [PATCH 21/69] add retry handling for auth provider setup (#484) --- cmd/wg-portal/main.go | 1 + internal/app/auth/auth.go | 118 ++++++++++++++++++++++++++++---------- 2 files changed, 88 insertions(+), 31 deletions(-) diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go index dbd2020..0c1dd20 100644 --- a/cmd/wg-portal/main.go +++ b/cmd/wg-portal/main.go @@ -87,6 +87,7 @@ func main() { authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager) internal.AssertNoError(err) + authenticator.StartBackgroundJobs(ctx) webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager) internal.AssertNoError(err) diff --git a/internal/app/auth/auth.go b/internal/app/auth/auth.go index 0ff8d9e..c6e5232 100644 --- a/internal/app/auth/auth.go +++ b/internal/app/auth/auth.go @@ -93,6 +93,8 @@ type Authenticator struct { // URL prefix for the callback endpoints, this is a combination of the external URL and the API prefix callbackUrlPrefix string + callbackUrl *url.URL + users UserManager } @@ -102,82 +104,136 @@ func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserM error, ) { a := &Authenticator{ - cfg: cfg, - bus: bus, - users: users, - callbackUrlPrefix: fmt.Sprintf("%s/api/v0", extUrl), + cfg: cfg, + bus: bus, + users: users, + callbackUrlPrefix: fmt.Sprintf("%s/api/v0", extUrl), + oauthAuthenticators: make(map[string]AuthenticatorOauth, len(cfg.OpenIDConnect)+len(cfg.OAuth)), + ldapAuthenticators: make(map[string]AuthenticatorLdap, len(cfg.Ldap)), } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - err := a.setupExternalAuthProviders(ctx) + parsedExtUrl, err := url.Parse(a.callbackUrlPrefix) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse external URL: %w", err) } + a.callbackUrl = parsedExtUrl return a, nil } -func (a *Authenticator) setupExternalAuthProviders(ctx context.Context) error { - extUrl, err := url.Parse(a.callbackUrlPrefix) - if err != nil { - return fmt.Errorf("failed to parse external url: %w", err) - } +// StartBackgroundJobs starts the background jobs for the authenticator. +// It sets up the external authentication providers (OIDC, OAuth, LDAP) and retries in case of errors. +func (a *Authenticator) StartBackgroundJobs(ctx context.Context) { + go func() { + // Initialize local copies of authentication providers to allow retry in case of errors + oidcQueue := a.cfg.OpenIDConnect + oauthQueue := a.cfg.OAuth + ldapQueue := a.cfg.Ldap - a.oauthAuthenticators = make(map[string]AuthenticatorOauth, len(a.cfg.OpenIDConnect)+len(a.cfg.OAuth)) - a.ldapAuthenticators = make(map[string]AuthenticatorLdap, len(a.cfg.Ldap)) + ticker := time.NewTicker(30 * time.Second) // Ticker for delay between retries + defer ticker.Stop() - for i := range a.cfg.OpenIDConnect { // OIDC - providerCfg := &a.cfg.OpenIDConnect[i] + for { + select { + case <-ticker.C: + failedOidc, failedOauth, failedLdap := a.setupExternalAuthProviders(oidcQueue, oauthQueue, ldapQueue) + if len(failedOidc) > 0 || len(failedOauth) > 0 || len(failedLdap) > 0 { + slog.Warn("failed to setup some external auth providers, retrying in 30 seconds", + "failedOidc", len(failedOidc), "failedOauth", len(failedOauth), "failedLdap", len(failedLdap)) + // Retry failed providers + oidcQueue = failedOidc + oauthQueue = failedOauth + ldapQueue = failedLdap + } else { + slog.Info("successfully setup all external auth providers") + return // Exit goroutine if all providers are set up successfully + } + case <-ctx.Done(): + slog.Info("context cancelled, stopping setup of external auth providers") + return // Exit goroutine if context is cancelled + } + } + }() +} + +func (a *Authenticator) setupExternalAuthProviders( + oidc []config.OpenIDConnectProvider, + oauth []config.OAuthProvider, + ldap []config.LdapProvider, +) ( + []config.OpenIDConnectProvider, + []config.OAuthProvider, + []config.LdapProvider, +) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var failedOidc []config.OpenIDConnectProvider + var failedOauth []config.OAuthProvider + var failedLdap []config.LdapProvider + + for i := range oidc { // OIDC + providerCfg := &oidc[i] providerId := strings.ToLower(providerCfg.ProviderName) if _, exists := a.oauthAuthenticators[providerId]; exists { - return fmt.Errorf("auth provider with name %s is already registerd", providerId) + // this is an unrecoverable error, we cannot register the same provider twice + slog.Error("OIDC auth provider is already registered", "name", providerId) + continue // skip this provider } - redirectUrl := *extUrl + redirectUrl := *a.callbackUrl redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback") provider, err := newOidcAuthenticator(ctx, redirectUrl.String(), providerCfg) if err != nil { - return fmt.Errorf("failed to setup oidc authentication provider %s: %w", providerCfg.ProviderName, err) + failedOidc = append(failedOidc, oidc[i]) + slog.Error("failed to setup oidc authentication provider", "name", providerId, "error", err) + continue } a.oauthAuthenticators[providerId] = provider } - for i := range a.cfg.OAuth { // PLAIN OAUTH - providerCfg := &a.cfg.OAuth[i] + for i := range oauth { // PLAIN OAUTH + providerCfg := &oauth[i] providerId := strings.ToLower(providerCfg.ProviderName) if _, exists := a.oauthAuthenticators[providerId]; exists { - return fmt.Errorf("auth provider with name %s is already registerd", providerId) + // this is an unrecoverable error, we cannot register the same provider twice + slog.Error("OAUTH auth provider is already registered", "name", providerId) + continue // skip this provider } - redirectUrl := *extUrl + redirectUrl := *a.callbackUrl redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback") provider, err := newPlainOauthAuthenticator(ctx, redirectUrl.String(), providerCfg) if err != nil { - return fmt.Errorf("failed to setup oauth authentication provider %s: %w", providerId, err) + failedOauth = append(failedOauth, oauth[i]) + slog.Error("failed to setup oauth authentication provider", "name", providerId, "error", err) + continue } a.oauthAuthenticators[providerId] = provider } - for i := range a.cfg.Ldap { // LDAP - providerCfg := &a.cfg.Ldap[i] + for i := range ldap { // LDAP + providerCfg := &ldap[i] providerId := strings.ToLower(providerCfg.URL) if _, exists := a.ldapAuthenticators[providerId]; exists { - return fmt.Errorf("auth provider with name %s is already registerd", providerId) + // this is an unrecoverable error, we cannot register the same provider twice + slog.Error("LDAP auth provider is already registered", "name", providerId) + continue // skip this provider } provider, err := newLdapAuthenticator(ctx, providerCfg) if err != nil { - return fmt.Errorf("failed to setup ldap authentication provider %s: %w", providerId, err) + failedLdap = append(failedLdap, ldap[i]) + slog.Error("failed to setup ldap authentication provider", "name", providerId, "error", err) + continue } a.ldapAuthenticators[providerId] = provider } - return nil + return failedOidc, failedOauth, failedLdap } // GetExternalLoginProviders returns a list of all available external login providers. From dc002b156b114baaad5ddcc92e06b750c360ee4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 26 Jul 2025 12:58:07 +0200 Subject: [PATCH 22/69] chore(deps): bump github.com/alexedwards/scs/v2 from 2.8.0 to 2.9.0 (#487) Bumps [github.com/alexedwards/scs/v2](https://github.com/alexedwards/scs) from 2.8.0 to 2.9.0. - [Release notes](https://github.com/alexedwards/scs/releases) - [Commits](https://github.com/alexedwards/scs/compare/v2.8.0...v2.9.0) --- updated-dependencies: - dependency-name: github.com/alexedwards/scs/v2 dependency-version: 2.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fa7499f..c3c9ab0 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.0 require ( github.com/a8m/envsubst v1.4.3 - github.com/alexedwards/scs/v2 v2.8.0 + github.com/alexedwards/scs/v2 v2.9.0 github.com/coreos/go-oidc/v3 v3.14.1 github.com/glebarez/sqlite v1.11.0 github.com/go-ldap/ldap/v3 v3.4.11 diff --git a/go.sum b/go.sum index 9b9a87b..ea9ed83 100644 --- a/go.sum +++ b/go.sum @@ -25,8 +25,8 @@ github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY= github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= -github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw= -github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= +github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= +github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= From ffef1f7b1246899d83af627fa399e0656d6e4eff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 26 Jul 2025 22:28:52 +0200 Subject: [PATCH 23/69] chore(deps): bump gorm.io/driver/sqlserver in the gorm group (#488) Bumps the gorm group with 1 update: [gorm.io/driver/sqlserver](https://github.com/go-gorm/sqlserver). Updates `gorm.io/driver/sqlserver` from 1.6.0 to 1.6.1 - [Commits](https://github.com/go-gorm/sqlserver/compare/v1.6.0...v1.6.1) --- updated-dependencies: - dependency-name: gorm.io/driver/sqlserver dependency-version: 1.6.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: gorm ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 +-- go.sum | 102 +++++++++++++++++++++++++++++++++++---------------------- 2 files changed, 65 insertions(+), 41 deletions(-) diff --git a/go.mod b/go.mod index c3c9ab0..dfa6bbf 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.6.0 gorm.io/driver/postgres v1.6.0 - gorm.io/driver/sqlserver v1.6.0 + gorm.io/driver/sqlserver v1.6.1 gorm.io/gorm v1.30.0 ) @@ -73,7 +73,7 @@ require ( github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/socket v0.5.1 // indirect - github.com/microsoft/go-mssqldb v1.8.0 // indirect + github.com/microsoft/go-mssqldb v1.8.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect diff --git a/go.sum b/go.sum index ea9ed83..d905e33 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,14 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0/go.mod h1:bhXu1AjYL+wutSL/kpSq6s7733q2Rb0yuot9Zgfqa/0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo= github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw= @@ -16,7 +17,7 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= -github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4= +github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -33,6 +34,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -80,12 +82,11 @@ github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaA github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI= github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI= github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E= -github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +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= github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= @@ -98,7 +99,6 @@ github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -119,12 +119,10 @@ github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFK github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= -github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= -github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= @@ -139,8 +137,11 @@ github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtL github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -157,23 +158,22 @@ github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/ github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= -github.com/microsoft/go-mssqldb v0.19.0/go.mod h1:ukJCBnnzLzpVF0qYRT+eg1e+eSwjeQ7IvenUv8QPook= -github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw= -github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo= +github.com/microsoft/go-mssqldb v1.8.2 h1:236sewazvC8FvG6Dr3bszrVhMkAl4KYImryLkRMCd0I= +github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= -github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA= @@ -188,13 +188,21 @@ github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2b github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swaggo/swag v1.16.5 h1:nMf2fEV1TetMTJb4XzD0Lz7jFfKJmJKGTygEey8NSxM= @@ -220,38 +228,46 @@ github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +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.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= @@ -266,44 +282,54 @@ golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220224120231-95c6836cb0e7/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= @@ -325,21 +351,19 @@ google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= -gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= -gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= +gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY= +gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA= From 6a8b28df881c689ebfa6f02dce28fb1bf6ff07d3 Mon Sep 17 00:00:00 2001 From: h44z Date: Sun, 27 Jul 2025 00:15:27 +0200 Subject: [PATCH 24/69] add dark mode (#482) (#489) --- frontend/index.html | 2 +- frontend/package-lock.json | 16 +++--- frontend/package.json | 4 +- frontend/src/App.vue | 60 ++++++++++++++++++++++- frontend/src/assets/base.css | 8 +++ frontend/src/components/PeerViewModal.vue | 4 +- frontend/src/views/AuditView.vue | 2 +- frontend/src/views/HomeView.vue | 8 +-- frontend/src/views/InterfaceView.vue | 6 ++- frontend/src/views/ProfileView.vue | 4 +- frontend/src/views/SettingsView.vue | 12 ++--- frontend/src/views/UserView.vue | 2 +- 12 files changed, 97 insertions(+), 31 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index fd8033f..287ef99 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,5 +1,5 @@ - + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index afbb37b..db118ca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,8 +14,8 @@ "@popperjs/core": "^2.11.8", "@simplewebauthn/browser": "^13.1.0", "@vojtechlanka/vue-tags-input": "^3.1.1", - "bootstrap": "^5.3.5", - "bootswatch": "^5.3.5", + "bootstrap": "^5.3.7", + "bootswatch": "^5.3.7", "flag-icons": "^7.3.2", "ip-address": "^10.0.1", "is-cidr": "^5.1.1", @@ -1048,9 +1048,9 @@ } }, "node_modules/bootstrap": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.5.tgz", - "integrity": "sha512-ct1CHKtiobRimyGzmsSldEtM03E8fcEX4Tb3dGXz1V8faRwM50+vfHwTzOxB3IlKO7m+9vTH3s/3C6T2EAPeTA==", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", + "integrity": "sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==", "funding": [ { "type": "github", @@ -1067,9 +1067,9 @@ } }, "node_modules/bootswatch": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/bootswatch/-/bootswatch-5.3.5.tgz", - "integrity": "sha512-1z8LNoUL5NHmv/hNROALQ6qtjw9OJIjMgP8ovBlIft+oI15b/mvnzxGL896iO9LtoDZH0Vdm+D2YW+j03GduSg==", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/bootswatch/-/bootswatch-5.3.7.tgz", + "integrity": "sha512-n0X99+Jmpmd4vgkli5KwMOuAkgdyUPhq7cIAwoGXbM6WhE/mmkWACfxpr7WZeG9Pdx509Ndi+2K1HlzXXOr8/Q==", "license": "MIT" }, "node_modules/buffer-builder": { diff --git a/frontend/package.json b/frontend/package.json index 0be893a..e226a95 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,8 +14,8 @@ "@popperjs/core": "^2.11.8", "@simplewebauthn/browser": "^13.1.0", "@vojtechlanka/vue-tags-input": "^3.1.1", - "bootstrap": "^5.3.5", - "bootswatch": "^5.3.5", + "bootstrap": "^5.3.7", + "bootswatch": "^5.3.7", "flag-icons": "^7.3.2", "ip-address": "^10.0.1", "is-cidr": "^5.1.1", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e6e20d2..cddea54 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -14,6 +14,10 @@ const settings = settingsStore() onMounted(async () => { console.log("Starting WireGuard Portal frontend..."); + // restore theme from localStorage + const theme = localStorage.getItem('wgTheme') || 'light'; + document.documentElement.setAttribute('data-bs-theme', theme); + await sec.LoadSecurityProperties(); await auth.LoadProviders(); @@ -40,6 +44,13 @@ const switchLanguage = function (lang) { } } +const switchTheme = function (theme) { + if (document.documentElement.getAttribute('data-bs-theme') !== theme) { + localStorage.setItem('wgTheme', theme); + document.documentElement.setAttribute('data-bs-theme', theme); + } +} + const languageFlag = computed(() => { // `this` points to the component instance let lang = appGlobal.$i18n.locale.toLowerCase(); @@ -125,6 +136,24 @@ const userDisplayName = computed(() => { + @@ -141,7 +170,7 @@ const userDisplayName = computed(() => {
-
diff --git a/frontend/src/views/AuditView.vue b/frontend/src/views/AuditView.vue index 108827a..ccd78f7 100644 --- a/frontend/src/views/AuditView.vue +++ b/frontend/src/views/AuditView.vue @@ -26,7 +26,7 @@ onMounted(async () => {
- +
diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 0291ca7..be87e56 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -13,21 +13,21 @@ const auth = authStore()

{{ $t('home.abstract') }}

-
+

{{ $t('home.profiles.headline') }}

{{ $t('home.profiles.abstract') }}


-

{{ $t('home.profiles.content') }}

+

{{ $t('home.profiles.content') }}

{{ $t('home.profiles.button') }}

-
+

{{ $t('home.admin.headline') }}

{{ $t('home.admin.abstract') }}


-

{{ $t('home.admin.content') }}

+

{{ $t('home.admin.content') }}

{{ $t('home.admin.button-admin') }} diff --git a/frontend/src/views/InterfaceView.vue b/frontend/src/views/InterfaceView.vue index 1084081..1cdb9fb 100644 --- a/frontend/src/views/InterfaceView.vue +++ b/frontend/src/views/InterfaceView.vue @@ -112,7 +112,7 @@ onMounted(async () => {

- - +
@@ -429,3 +429,5 @@ onMounted(async () => {
+ diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue index a5daec2..ea73fab 100644 --- a/frontend/src/views/ProfileView.vue +++ b/frontend/src/views/ProfileView.vue @@ -65,7 +65,7 @@ onMounted(async () => {
-
@@ -73,7 +73,7 @@ onMounted(async () => {
- - +
From 3f76aa416f6640db12f91b0059ca3388778d2e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcin=20Wo=C5=BAniak?= Date: Sun, 27 Jul 2025 23:32:34 +0200 Subject: [PATCH 25/69] chore(logs): added more debug logs and reformated those files using gofmt (#490) --- internal/adapters/wireguard.go | 40 ++++++++++++++++++++++++---- internal/app/api/core/server.go | 1 + internal/app/wireguard/statistics.go | 1 + 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/internal/adapters/wireguard.go b/internal/adapters/wireguard.go index 6c15771..327d54a 100644 --- a/internal/adapters/wireguard.go +++ b/internal/adapters/wireguard.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "github.com/vishvananda/netlink" @@ -16,8 +17,9 @@ import ( // WgRepo implements all low-level WireGuard interactions. type WgRepo struct { - wg lowlevel.WireGuardClient - nl lowlevel.NetlinkClient + wg lowlevel.WireGuardClient + nl lowlevel.NetlinkClient + log *slog.Logger } // NewWireGuardRepository creates a new WgRepo instance. @@ -31,8 +33,9 @@ func NewWireGuardRepository() *WgRepo { nl := &lowlevel.NetlinkManager{} repo := &WgRepo{ - wg: wg, - nl: nl, + wg: wg, + nl: nl, + log: slog.Default().With(slog.String("adapter", "wireguard")), } return repo @@ -40,8 +43,10 @@ func NewWireGuardRepository() *WgRepo { // GetInterfaces returns all existing WireGuard interfaces. func (r *WgRepo) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) { + r.log.Debug("getting all interfaces") devices, err := r.wg.Devices() if err != nil { + r.log.Error("failed to get devices", "error", err) return nil, fmt.Errorf("device list error: %w", err) } @@ -60,14 +65,17 @@ func (r *WgRepo) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, e // GetInterface returns the interface with the given id. // If no interface is found, an error os.ErrNotExist is returned. func (r *WgRepo) GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) { + r.log.Debug("getting interface", "id", id) return r.getInterface(id) } // GetPeers returns all peers associated with the given interface id. // If the requested interface is found, an error os.ErrNotExist is returned. func (r *WgRepo) GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) { + r.log.Debug("getting peers for interface", "deviceId", deviceId) device, err := r.wg.Device(string(deviceId)) if err != nil { + r.log.Error("failed to get device", "deviceId", deviceId, "error", err) return nil, fmt.Errorf("device error: %w", err) } @@ -90,6 +98,7 @@ func (r *WgRepo) GetPeer( deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier, ) (*domain.PhysicalPeer, error) { + r.log.Debug("getting peer", "deviceId", deviceId, "peerId", id) return r.getPeer(deviceId, id) } @@ -174,25 +183,31 @@ func (r *WgRepo) SaveInterface( id domain.InterfaceIdentifier, updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error), ) error { + r.log.Debug("saving interface", "id", id) physicalInterface, err := r.getOrCreateInterface(id) if err != nil { + r.log.Error("failed to get or create interface", "id", id, "error", err) return err } if updateFunc != nil { physicalInterface, err = updateFunc(physicalInterface) if err != nil { + r.log.Error("interface update function failed", "id", id, "error", err) return err } } if err := r.updateLowLevelInterface(physicalInterface); err != nil { + r.log.Error("failed to update low level interface", "id", id, "error", err) return err } if err := r.updateWireGuardInterface(physicalInterface); err != nil { + r.log.Error("failed to update wireguard interface", "id", id, "error", err) return err } + r.log.Debug("successfully saved interface", "id", id) return nil } @@ -323,10 +338,13 @@ func (r *WgRepo) updateWireGuardInterface(pi *domain.PhysicalInterface) error { // DeleteInterface deletes the interface with the given id. // If the requested interface is found, no error is returned. func (r *WgRepo) DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error { + r.log.Debug("deleting interface", "id", id) if err := r.deleteLowLevelInterface(id); err != nil { + r.log.Error("failed to delete low level interface", "id", id, "error", err) return err } + r.log.Debug("successfully deleted interface", "id", id) return nil } @@ -356,20 +374,25 @@ func (r *WgRepo) SavePeer( id domain.PeerIdentifier, updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error), ) error { + r.log.Debug("saving peer", "deviceId", deviceId, "peerId", id) physicalPeer, err := r.getOrCreatePeer(deviceId, id) if err != nil { + r.log.Error("failed to get or create peer", "deviceId", deviceId, "peerId", id, "error", err) return err } physicalPeer, err = updateFunc(physicalPeer) if err != nil { + r.log.Error("peer update function failed", "deviceId", deviceId, "peerId", id, "error", err) return err } if err := r.updatePeer(deviceId, physicalPeer); err != nil { + r.log.Error("failed to update peer", "deviceId", deviceId, "peerId", id, "error", err) return err } + r.log.Debug("successfully saved peer", "deviceId", deviceId, "peerId", id) return nil } @@ -441,6 +464,7 @@ func (r *WgRepo) updatePeer(deviceId domain.InterfaceIdentifier, pp *domain.Phys err := r.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}}) if err != nil { + r.log.Error("failed to configure device for peer update", "deviceId", deviceId, "peerId", pp.Identifier, "error", err) return err } @@ -450,15 +474,20 @@ func (r *WgRepo) updatePeer(deviceId domain.InterfaceIdentifier, pp *domain.Phys // DeletePeer deletes the peer with the given id. // If the requested interface or peer is found, no error is returned. func (r *WgRepo) DeletePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error { + r.log.Debug("deleting peer", "deviceId", deviceId, "peerId", id) if !id.IsPublicKey() { - return errors.New("invalid public key") + err := errors.New("invalid public key") + r.log.Error("invalid peer id", "peerId", id, "error", err) + return err } err := r.deletePeer(deviceId, id) if err != nil { + r.log.Error("failed to delete peer", "deviceId", deviceId, "peerId", id, "error", err) return err } + r.log.Debug("successfully deleted peer", "deviceId", deviceId, "peerId", id) return nil } @@ -470,6 +499,7 @@ func (r *WgRepo) deletePeer(deviceId domain.InterfaceIdentifier, id domain.PeerI err := r.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}}) if err != nil { + r.log.Error("failed to configure device for peer deletion", "deviceId", deviceId, "peerId", id, "error", err) return err } diff --git a/internal/app/api/core/server.go b/internal/app/api/core/server.go index fe0d1e6..87b5519 100644 --- a/internal/app/api/core/server.go +++ b/internal/app/api/core/server.go @@ -100,6 +100,7 @@ func (s *Server) Run(ctx context.Context, listenAddress string) { srvContext, cancelFn := context.WithCancel(ctx) go func() { var err error + slog.Debug("starting server", "certFile", s.cfg.Web.CertFile, "keyFile", s.cfg.Web.KeyFile) if s.cfg.Web.CertFile != "" && s.cfg.Web.KeyFile != "" { err = srv.ListenAndServeTLS(s.cfg.Web.CertFile, s.cfg.Web.KeyFile) } else { diff --git a/internal/app/wireguard/statistics.go b/internal/app/wireguard/statistics.go index dcc8b36..9ab8946 100644 --- a/internal/app/wireguard/statistics.go +++ b/internal/app/wireguard/statistics.go @@ -197,6 +197,7 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) { p.CalcConnected() if wasConnected != p.IsConnected { + slog.Debug("peer connection state changed", "peer", peer.Identifier, "connected", p.IsConnected) connectionStateChanged = true newPeerStatus = *p // store new status for event publishing } From c20f17cddfa813b1e6e602018e6fdb5f8d787166 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Sat, 9 Aug 2025 15:55:29 +0200 Subject: [PATCH 26/69] fix multi-peer generation, fix prefix handling (#491) --- .../src/components/PeerMultiCreateModal.vue | 4 +- .../app/api/core/assets/doc/v0_swagger.json | 2 +- .../app/api/core/assets/doc/v0_swagger.yaml | 2 +- internal/app/api/core/server.go | 3 +- internal/app/api/v0/model/models_peer.go | 4 +- internal/app/wireguard/wireguard_peers.go | 69 +++++++++---------- internal/domain/peer.go | 2 +- 7 files changed, 41 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/PeerMultiCreateModal.vue b/frontend/src/components/PeerMultiCreateModal.vue index 5201f03..f5a2c87 100644 --- a/frontend/src/components/PeerMultiCreateModal.vue +++ b/frontend/src/components/PeerMultiCreateModal.vue @@ -32,7 +32,7 @@ const selectedInterface = computed(() => { function freshForm() { return { Identifiers: [], - Suffix: "", + Prefix: "", } } @@ -102,7 +102,7 @@ async function save() {
- + {{ $t('modals.peer-multi-create.prefix.description') }}
diff --git a/internal/app/api/core/assets/doc/v0_swagger.json b/internal/app/api/core/assets/doc/v0_swagger.json index fc2ebc8..d4c39d1 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.json +++ b/internal/app/api/core/assets/doc/v0_swagger.json @@ -1969,7 +1969,7 @@ "type": "string" } }, - "Suffix": { + "Prefix": { "type": "string" } } diff --git a/internal/app/api/core/assets/doc/v0_swagger.yaml b/internal/app/api/core/assets/doc/v0_swagger.yaml index 479cb8d..bb0de5f 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.yaml +++ b/internal/app/api/core/assets/doc/v0_swagger.yaml @@ -206,7 +206,7 @@ definitions: items: type: string type: array - Suffix: + Prefix: type: string type: object model.Peer: diff --git a/internal/app/api/core/server.go b/internal/app/api/core/server.go index 87b5519..4cc986c 100644 --- a/internal/app/api/core/server.go +++ b/internal/app/api/core/server.go @@ -139,7 +139,8 @@ func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) { s.versions[version].HandleFunc("GET /swagger/index.html", s.rapiDocHandler(version)) // Deprecated: old link s.versions[version].HandleFunc("GET /doc.html", s.rapiDocHandler(version)) - groupSetupFn(s.versions[version]) + versionGroup := s.versions[version].Group() + groupSetupFn(versionGroup) } } } diff --git a/internal/app/api/v0/model/models_peer.go b/internal/app/api/v0/model/models_peer.go index 4f8f2b3..b0e2f7e 100644 --- a/internal/app/api/v0/model/models_peer.go +++ b/internal/app/api/v0/model/models_peer.go @@ -172,13 +172,13 @@ func NewDomainPeer(src *Peer) *domain.Peer { type MultiPeerRequest struct { Identifiers []string `json:"Identifiers"` - Suffix string `json:"Suffix"` + Prefix string `json:"Prefix"` } func NewDomainPeerCreationRequest(src *MultiPeerRequest) *domain.PeerCreationRequest { return &domain.PeerCreationRequest{ UserIdentifiers: src.Identifiers, - Suffix: src.Suffix, + Prefix: src.Prefix, } } diff --git a/internal/app/wireguard/wireguard_peers.go b/internal/app/wireguard/wireguard_peers.go index 1406e85..500d5bb 100644 --- a/internal/app/wireguard/wireguard_peers.go +++ b/internal/app/wireguard/wireguard_peers.go @@ -188,29 +188,29 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee sessionUser := domain.GetUserInfo(ctx) - // Enforce peer limit for non-admin users if LimitAdditionalUserPeers is set - if m.cfg.Core.SelfProvisioningAllowed && !sessionUser.IsAdmin && m.cfg.Advanced.LimitAdditionalUserPeers > 0 { - peers, err := m.db.GetUserPeers(ctx, peer.UserIdentifier) - if err != nil { - return nil, fmt.Errorf("failed to fetch peers for user %s: %w", peer.UserIdentifier, err) - } - // Count enabled peers (disabled IS NULL) - peerCount := 0 - for _, p := range peers { - if !p.IsDisabled() { - peerCount++ - } - } - totalAllowedPeers := 1 + m.cfg.Advanced.LimitAdditionalUserPeers // 1 default peer + x additional peers - if peerCount >= totalAllowedPeers { - slog.WarnContext(ctx, "peer creation blocked due to limit", - "user", peer.UserIdentifier, - "current_count", peerCount, - "allowed_count", totalAllowedPeers) - return nil, fmt.Errorf("peer limit reached (%d peers allowed): %w", totalAllowedPeers, domain.ErrNoPermission) - } - } - + // Enforce peer limit for non-admin users if LimitAdditionalUserPeers is set + if m.cfg.Core.SelfProvisioningAllowed && !sessionUser.IsAdmin && m.cfg.Advanced.LimitAdditionalUserPeers > 0 { + peers, err := m.db.GetUserPeers(ctx, peer.UserIdentifier) + if err != nil { + return nil, fmt.Errorf("failed to fetch peers for user %s: %w", peer.UserIdentifier, err) + } + // Count enabled peers (disabled IS NULL) + peerCount := 0 + for _, p := range peers { + if !p.IsDisabled() { + peerCount++ + } + } + totalAllowedPeers := 1 + m.cfg.Advanced.LimitAdditionalUserPeers // 1 default peer + x additional peers + if peerCount >= totalAllowedPeers { + slog.WarnContext(ctx, "peer creation blocked due to limit", + "user", peer.UserIdentifier, + "current_count", peerCount, + "allowed_count", totalAllowedPeers) + return nil, fmt.Errorf("peer limit reached (%d peers allowed): %w", totalAllowedPeers, + domain.ErrNoPermission) + } + } existingPeer, err := m.db.GetPeer(ctx, peer.Identifier) if err != nil && !errors.Is(err, domain.ErrNotFound) { @@ -257,7 +257,7 @@ func (m Manager) CreateMultiplePeers( return nil, err } - var newPeers []*domain.Peer + createdPeers := make([]domain.Peer, 0, len(r.UserIdentifiers)) for _, id := range r.UserIdentifiers { freshPeer, err := m.PreparePeer(ctx, interfaceId) @@ -266,27 +266,22 @@ func (m Manager) CreateMultiplePeers( } freshPeer.UserIdentifier = domain.UserIdentifier(id) // use id as user identifier. peers are allowed to have invalid user identifiers - if r.Suffix != "" { - freshPeer.DisplayName += " " + r.Suffix + if r.Prefix != "" { + freshPeer.DisplayName = r.Prefix + " " + freshPeer.DisplayName } if err := m.validatePeerCreation(ctx, nil, freshPeer); err != nil { return nil, fmt.Errorf("creation not allowed: %w", err) } - newPeers = append(newPeers, freshPeer) - } + // Save immediately to reserve the assigned IPs so the next prepared peer gets the next free IPs + if err := m.savePeers(ctx, freshPeer); err != nil { + return nil, fmt.Errorf("failed to create new peer %s: %w", freshPeer.Identifier, err) + } - err := m.savePeers(ctx, newPeers...) - if err != nil { - return nil, fmt.Errorf("failed to create new peers: %w", err) - } + createdPeers = append(createdPeers, *freshPeer) - createdPeers := make([]domain.Peer, len(newPeers)) - for i := range newPeers { - createdPeers[i] = *newPeers[i] - - m.bus.Publish(app.TopicPeerCreated, *newPeers[i]) + m.bus.Publish(app.TopicPeerCreated, *freshPeer) } return createdPeers, nil diff --git a/internal/domain/peer.go b/internal/domain/peer.go index 8de712a..93404eb 100644 --- a/internal/domain/peer.go +++ b/internal/domain/peer.go @@ -269,5 +269,5 @@ func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) { type PeerCreationRequest struct { UserIdentifiers []string - Suffix string + Prefix string } From 22468291511946711007be83916a9f92aa8b41dd Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Sat, 9 Aug 2025 15:56:02 +0200 Subject: [PATCH 27/69] chore: update deps (except routegroup, it breaks the api) --- go.mod | 40 ++++++++++----------- go.sum | 112 +++++++++++++++++++++++++++++++-------------------------- 2 files changed, 82 insertions(+), 70 deletions(-) diff --git a/go.mod b/go.mod index dfa6bbf..2d44939 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.0 require ( github.com/a8m/envsubst v1.4.3 github.com/alexedwards/scs/v2 v2.9.0 - github.com/coreos/go-oidc/v3 v3.14.1 + github.com/coreos/go-oidc/v3 v3.15.0 github.com/glebarez/sqlite v1.11.0 github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-pkgz/routegroup v1.4.1 @@ -13,23 +13,23 @@ require ( github.com/go-webauthn/webauthn v0.13.4 github.com/google/uuid v1.6.0 github.com/prometheus-community/pro-bing v0.7.0 - github.com/prometheus/client_golang v1.22.0 + github.com/prometheus/client_golang v1.23.0 github.com/stretchr/testify v1.10.0 - github.com/swaggo/swag v1.16.5 + github.com/swaggo/swag v1.16.6 github.com/vardius/message-bus v1.1.5 github.com/vishvananda/netlink v1.3.1 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.40.0 + golang.org/x/crypto v0.41.0 golang.org/x/oauth2 v0.30.0 - golang.org/x/sys v0.34.0 + golang.org/x/sys v0.35.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 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlserver v1.6.1 - gorm.io/gorm v1.30.0 + gorm.io/gorm v1.30.1 ) require ( @@ -51,7 +51,7 @@ require ( github.com/go-openapi/swag v0.23.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-sql-driver/mysql v1.9.2 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-test/deep v1.1.1 // indirect github.com/go-webauthn/x v0.1.23 // indirect github.com/golang-jwt/jwt/v5 v5.2.3 // indirect @@ -61,7 +61,7 @@ require ( github.com/google/go-tpm v0.9.5 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.7.4 // indirect + github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -73,30 +73,30 @@ require ( github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/socket v0.5.1 // indirect - github.com/microsoft/go-mssqldb v1.8.2 // indirect + github.com/microsoft/go-mssqldb v1.9.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.63.0 // indirect - github.com/prometheus/procfs v0.16.0 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.41.0 // indirect + golang.org/x/exp v0.0.0-20250808145144-a408d31f581a // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.16.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.0 // indirect - golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.36.0 // indirect + golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect google.golang.org/protobuf v1.36.6 // indirect - modernc.org/libc v1.63.0 // indirect + modernc.org/libc v1.66.6 // indirect modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.10.0 // indirect - modernc.org/sqlite v1.37.0 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.38.2 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index d905e33..9a72b66 100644 --- a/go.sum +++ b/go.sum @@ -2,24 +2,30 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo= github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY= @@ -32,8 +38,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= -github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg= +github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -74,8 +80,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= -github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ= @@ -111,8 +117,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= -github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -158,8 +164,9 @@ github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/ github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= -github.com/microsoft/go-mssqldb v1.8.2 h1:236sewazvC8FvG6Dr3bszrVhMkAl4KYImryLkRMCd0I= github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= +github.com/microsoft/go-mssqldb v1.9.2 h1:nY8TmFMQOHpm2qVWo6y4I2mAmVdZqlGiMGAYt64Ibbs= +github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -178,14 +185,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA= github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= -github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= -github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= -github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= @@ -205,8 +212,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/swaggo/swag v1.16.5 h1:nMf2fEV1TetMTJb4XzD0Lz7jFfKJmJKGTygEey8NSxM= -github.com/swaggo/swag v1.16.5/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA= github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= @@ -227,6 +234,8 @@ github.com/yeqown/go-qrcode/writer/compressed v1.0.1/go.mod h1:BJScsGUIKM+eg0CCL github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0= github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= @@ -239,18 +248,18 @@ 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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= -golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20250808145144-a408d31f581a h1:Y+7uR/b1Mw2iSXZ3G//1haIiSElDQZ8KWh0h+sZPG90= +golang.org/x/exp v0.0.0-20250808145144-a408d31f581a/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -268,8 +277,8 @@ 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.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -301,8 +310,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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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= @@ -331,19 +340,19 @@ 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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 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= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= -golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= +golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= +golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= @@ -364,28 +373,31 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY= gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= -gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= -modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA= -modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc= -modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY= -modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= +gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I= +modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/libc v1.63.0 h1:wKzb61wOGCzgahQBORb1b0dZonh8Ufzl/7r4Yf1D5YA= -modernc.org/libc v1.63.0/go.mod h1:wDzH1mgz1wUIEwottFt++POjGRO9sgyQKrpXaz3x89E= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.6 h1:RyQpwAhM/19nXD8y3iejM/AjmKwY2TjxZTlUWTsWw2U= +modernc.org/libc v1.66.6/go.mod h1:j8z0EYAuumoMQ3+cWXtmw6m+LYn3qm8dcZDFtFTSq+M= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= -modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= -modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= From 131413b470f6e5ff25df4517d568b026a454ead3 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Sun, 10 Aug 2025 10:59:28 +0200 Subject: [PATCH 28/69] chore: update routegroup dependency --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 2d44939..47fb6a8 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/coreos/go-oidc/v3 v3.15.0 github.com/glebarez/sqlite v1.11.0 github.com/go-ldap/ldap/v3 v3.4.11 - github.com/go-pkgz/routegroup v1.4.1 + github.com/go-pkgz/routegroup v1.5.1 github.com/go-playground/validator/v10 v10.27.0 github.com/go-webauthn/webauthn v0.13.4 github.com/google/uuid v1.6.0 @@ -45,7 +45,7 @@ require ( github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-jose/go-jose/v4 v4.1.0 // indirect - github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonpointer v0.21.2 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect diff --git a/go.sum b/go.sum index 9a72b66..33083d4 100644 --- a/go.sum +++ b/go.sum @@ -62,16 +62,16 @@ github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0 github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= -github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= -github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA= +github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= -github.com/go-pkgz/routegroup v1.4.1 h1:iw1yW3lXuurZZOv/DF9fY8Mkpvy6J9UjBiP1oDIQE/s= -github.com/go-pkgz/routegroup v1.4.1/go.mod h1:kDDPDRLRiRY1vnENrZJw1jQAzQX7fvsbsHGRQFNQfKc= +github.com/go-pkgz/routegroup v1.5.1 h1:hwVU4w2ltMQXIGEM4WIM0aWyRn7FsZbfbZIlPH7f1Rk= +github.com/go-pkgz/routegroup v1.5.1/go.mod h1:kDDPDRLRiRY1vnENrZJw1jQAzQX7fvsbsHGRQFNQfKc= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= From a86f83a2195f5c5926acd23ccb53ee8e1a326de5 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Sun, 10 Aug 2025 14:18:43 +0200 Subject: [PATCH 29/69] ensure that deleted peers are restored once the interface is re-enabled --- .../app/wireguard/wireguard_interfaces.go | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal/app/wireguard/wireguard_interfaces.go b/internal/app/wireguard/wireguard_interfaces.go index 02f1da1..17a28bc 100644 --- a/internal/app/wireguard/wireguard_interfaces.go +++ b/internal/app/wireguard/wireguard_interfaces.go @@ -555,6 +555,27 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) ( return nil, fmt.Errorf("post-save hooks failed: %w", err) } + // If the interface has just been enabled, restore its peers on the physical controller + if !oldEnabled && newEnabled { + peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier) + if err != nil { + return nil, fmt.Errorf("failed to load peers for interface %s: %w", iface.Identifier, err) + } + for _, peer := range peers { + saveErr := m.wg.SavePeer(ctx, iface.Identifier, peer.Identifier, + func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) { + domain.MergeToPhysicalPeer(pp, &peer) + return pp, nil + }) + if saveErr != nil { + return nil, fmt.Errorf("failed to restore peer %s for interface %s: %w", peer.Identifier, + iface.Identifier, saveErr) + } + } + // notify that peers for this interface have changed so config/routes can be updated + m.bus.Publish(app.TopicPeerInterfaceUpdated, iface.Identifier) + } + m.bus.Publish(app.TopicAuditInterfaceChanged, domain.AuditEventWrapper[audit.InterfaceEvent]{ Ctx: ctx, Event: audit.InterfaceEvent{ From 112f6bfb77b0e91efa1faf4590005724725a0e43 Mon Sep 17 00:00:00 2001 From: h44z Date: Sun, 10 Aug 2025 14:42:02 +0200 Subject: [PATCH 30/69] Mikrotik integration (#467) Allow MikroTik routes as WireGuard backends --- README.md | 1 + cmd/wg-portal/main.go | 5 +- docs/documentation/configuration/overview.md | 63 ++ docs/documentation/usage/backends.md | 57 ++ .../src/components/InterfaceEditModal.vue | 80 +- frontend/src/components/PeerEditModal.vue | 23 +- .../src/components/PeerMultiCreateModal.vue | 11 +- frontend/src/components/UserEditModal.vue | 20 +- frontend/src/components/UserPeerEditModal.vue | 23 +- frontend/src/helpers/models.js | 1 + frontend/src/lang/translations/de.json | 9 +- frontend/src/lang/translations/en.json | 9 +- frontend/src/lang/translations/fr.json | 2 +- frontend/src/lang/translations/ko.json | 2 +- frontend/src/lang/translations/pt.json | 2 +- frontend/src/lang/translations/ru.json | 2 +- frontend/src/lang/translations/uk.json | 2 +- frontend/src/lang/translations/vi.json | 2 +- frontend/src/lang/translations/zh.json | 2 +- frontend/src/views/InterfaceView.vue | 34 +- internal/adapters/wgcontroller/local.go | 864 ++++++++++++++++++ internal/adapters/wgcontroller/mikrotik.go | 829 +++++++++++++++++ .../app/api/v0/handlers/endpoint_config.go | 36 +- internal/app/api/v0/model/models.go | 20 +- internal/app/api/v0/model/models_interface.go | 8 + internal/app/app.go | 2 +- internal/app/wireguard/controller_manager.go | 166 ++++ internal/app/wireguard/statistics.go | 58 +- internal/app/wireguard/wireguard.go | 23 +- .../app/wireguard/wireguard_interfaces.go | 148 +-- internal/app/wireguard/wireguard_peers.go | 56 +- internal/config/backend.go | 94 ++ internal/config/config.go | 16 + internal/domain/controller.go | 32 + internal/domain/interface.go | 93 +- internal/domain/peer.go | 87 +- internal/domain/statistics.go | 26 +- internal/logger.go | 11 +- internal/lowlevel/mikrotik.go | 435 +++++++++ mkdocs.yml | 1 + 40 files changed, 3150 insertions(+), 205 deletions(-) create mode 100644 docs/documentation/usage/backends.md create mode 100644 internal/adapters/wgcontroller/local.go create mode 100644 internal/adapters/wgcontroller/mikrotik.go create mode 100644 internal/app/wireguard/controller_manager.go create mode 100644 internal/config/backend.go create mode 100644 internal/domain/controller.go create mode 100644 internal/lowlevel/mikrotik.go diff --git a/README.md b/README.md index 018b748..035c69f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos * Docker ready * Can be used with existing WireGuard setups * Support for multiple WireGuard interfaces +* Supports multiple WireGuard backends (wgctrl or MikroTik [BETA]) * Peer Expiry Feature * Handles route and DNS settings like wg-quick does * Exposes Prometheus metrics for monitoring and alerting diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go index 0c1dd20..97f0b67 100644 --- a/cmd/wg-portal/main.go +++ b/cmd/wg-portal/main.go @@ -50,7 +50,8 @@ func main() { database, err := adapters.NewSqlRepository(rawDb) internal.AssertNoError(err) - wireGuard := adapters.NewWireGuardRepository() + wireGuard, err := wireguard.NewControllerManager(cfg) + internal.AssertNoError(err) wgQuick := adapters.NewWgQuickRepo() @@ -134,7 +135,7 @@ func main() { apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers) apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces) apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers) - apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth) + apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard) apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth) apiFrontend := handlersV0.NewRestApi(apiV0Session, diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index dd20d79..1268582 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -24,6 +24,9 @@ core: self_provisioning_allowed: false import_existing: true restore_state: true + +backend: + default: local advanced: log_level: info @@ -102,6 +105,7 @@ webhook: Below you will find sections like [`core`](#core), +[`backend`](#backend), [`advanced`](#advanced), [`database`](#database), [`statistics`](#statistics), @@ -165,6 +169,65 @@ More advanced options are found in the subsequent `Advanced` section. --- +## Backend + +Configuration options for the WireGuard backend, which manages the WireGuard interfaces and peers. +The current MikroTik backend is in **BETA** and may not support all features. + +### `default` +- **Default:** `local` +- **Description:** The default backend to use for managing WireGuard interfaces. + Valid options are: `local`, or other backend id's configured in the `mikrotik` section. + +### Mikrotik + +The `mikrotik` array contains a list of MikroTik backend definitions. Each entry describes how to connect to a MikroTik RouterOS instance that hosts WireGuard interfaces. + +Below are the properties for each entry inside `backend.mikrotik`: + +#### `id` +- **Default:** *(empty)* +- **Description:** A unique identifier for this backend. + This value can be referenced by `backend.default` to use this backend as default. + The identifier must be unique across all backends and must not use the reserved keyword `local`. + +#### `display_name` +- **Default:** *(empty)* +- **Description:** A human-friendly display name for this backend. If omitted, the `id` will be used as the display name. + +#### `api_url` +- **Default:** *(empty)* +- **Description:** Base URL of the MikroTik REST API, including scheme and path, e.g., `https://10.10.10.10:8729/rest`. + +#### `api_user` +- **Default:** *(empty)* +- **Description:** Username for authenticating against the MikroTik API. + Ensure that the user has sufficient permissions to manage WireGuard interfaces and peers. + +#### `api_password` +- **Default:** *(empty)* +- **Description:** Password for the specified API user. + +#### `api_verify_tls` +- **Default:** `false` +- **Description:** Whether to verify the TLS certificate of the MikroTik API endpoint. Set to `false` to allow self-signed certificates (not recommended for production). + +#### `api_timeout` +- **Default:** `30s` +- **Description:** Timeout for API requests to the MikroTik device. Uses Go duration format (e.g., `10s`, `1m`). If omitted, a default of 30 seconds is used. + +#### `concurrency` +- **Default:** `5` +- **Description:** Maximum number of concurrent API requests the backend will issue when enumerating interfaces and their details. If `0` or negative, a sane default of `5` is used. + +#### `debug` +- **Default:** `false` +- **Description:** Enable verbose debug logging for the MikroTik backend. + +For more details on configuring the MikroTik backend, see the [Backends](../usage/backends.md) documentation. + +--- + ## Advanced Additional or more specialized configuration options for logging and interface creation details. diff --git a/docs/documentation/usage/backends.md b/docs/documentation/usage/backends.md new file mode 100644 index 0000000..e891d95 --- /dev/null +++ b/docs/documentation/usage/backends.md @@ -0,0 +1,57 @@ +# Backends + +WireGuard Portal can manage WireGuard interfaces and peers on different backends. +Each backend represents a system where interfaces actually live. +You can register multiple backends and choose which one to use per interface. +A global default backend determines where newly created interfaces go (unless you explicitly choose another in the UI). + +**Supported backends:** +- **Local** (default): Manages interfaces on the host running WireGuard Portal (Linux WireGuard via wgctrl). Use this when the portal should directly configure wg devices on the same server. +- **MikroTik** RouterOS (_beta_): Manages interfaces and peers on MikroTik devices via the RouterOS REST API. Use this to control WG interfaces on RouterOS v7+. + +How backend selection works: +- The default backend is configured at `backend.default` (_local_ or the id of a defined MikroTik backend). + New interfaces created in the UI will use this backend by default. +- Each interface stores its backend. You can select a different backend when creating a new interface. + +## Configuring MikroTik backends (RouterOS v7+) + +> :warning: The MikroTik backend is currently marked beta. While basic functionality is implemented, some advanced features are not yet implemented or contain bugs. Please test carefully before using in production. + +The MikroTik backend uses the [REST API](https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API) under a base URL ending with /rest. +You can register one or more MikroTik devices as backends for a single WireGuard Portal instance. + +### Prerequisites on MikroTik: +- RouterOS v7 with WireGuard support. +- REST API enabled and reachable over HTTP(S). A typical base URL is https://:8729/rest or https:///rest depending on your service setup. +- A dedicated RouterOS user with the following group permissions: + - **api** (for logging in via REST API) + - **rest-api** (for logging in via REST API) + - **read** (to read interface and peer data) + - **write** (to create/update interfaces and peers) + - **test** (to perform ping checks) + - **sensitive** (to read private keys) +- TLS certificate on the device is recommended. If you use a self-signed certificate during testing, set `api_verify_tls`: _false_ in wg-portal (not recommended for production). + +Example WireGuard Portal configuration (config/config.yaml): + +```yaml +backend: + # default backend decides where new interfaces are created + default: mikrotik-prod + + mikrotik: + - id: mikrotik-prod # unique id, not "local" + display_name: RouterOS RB5009 # optional nice name + api_url: https://10.10.10.10/rest + api_user: wgportal + api_password: a-super-secret-password + api_verify_tls: true # set to false only if using self-signed during testing + api_timeout: 30s # maximum request duration + concurrency: 5 # limit parallel REST calls to device + debug: false # verbose logging for this backend +``` + +### Known limitations: +- The MikroTik backend is still in beta. Some features may not work as expected. +- Not all WireGuard Portal features are supported yet (e.g., no support for interface hooks) \ No newline at end of file diff --git a/frontend/src/components/InterfaceEditModal.vue b/frontend/src/components/InterfaceEditModal.vue index d23b490..586290d 100644 --- a/frontend/src/components/InterfaceEditModal.vue +++ b/frontend/src/components/InterfaceEditModal.vue @@ -10,11 +10,13 @@ import isCidr from "is-cidr"; import {isIP} from 'is-ip'; import { freshInterface } from '@/helpers/models'; import {peerStore} from "@/stores/peers"; +import {settingsStore} from "@/stores/settings"; const { t } = useI18n() const interfaces = interfaceStore() const peers = peerStore() +const settings = settingsStore() const props = defineProps({ interfaceId: String, @@ -48,6 +50,26 @@ const currentTags = ref({ PeerDefDnsSearch: "" }) const formData = ref(freshInterface()) +const isSaving = ref(false) +const isDeleting = ref(false) +const isApplyingDefaults = ref(false) + +const isBackendValid = computed(() => { + if (!props.visible || !selectedInterface.value) { + return true // if modal is not visible or no interface is selected, we don't care about backend validity + } + + let backendId = selectedInterface.value.Backend + + let valid = false + let availableBackends = settings.Setting('AvailableBackends') || [] + availableBackends.forEach(backend => { + if (backend.Id === backendId) { + valid = true + } + }) + return valid +}) // functions @@ -61,6 +83,7 @@ watch(() => props.visible, async (newValue, oldValue) => { formData.value.Identifier = interfaces.Prepared.Identifier formData.value.DisplayName = interfaces.Prepared.DisplayName formData.value.Mode = interfaces.Prepared.Mode + formData.value.Backend = interfaces.Prepared.Backend formData.value.PublicKey = interfaces.Prepared.PublicKey formData.value.PrivateKey = interfaces.Prepared.PrivateKey @@ -99,6 +122,7 @@ watch(() => props.visible, async (newValue, oldValue) => { formData.value.Identifier = selectedInterface.value.Identifier formData.value.DisplayName = selectedInterface.value.DisplayName formData.value.Mode = selectedInterface.value.Mode + formData.value.Backend = selectedInterface.value.Backend formData.value.PublicKey = selectedInterface.value.PublicKey formData.value.PrivateKey = selectedInterface.value.PrivateKey @@ -237,6 +261,8 @@ function handleChangePeerDefDnsSearch(tags) { } async function save() { + if (isSaving.value) return + isSaving.value = true try { if (props.interfaceId!=='#NEW#') { await interfaces.UpdateInterface(selectedInterface.value.Identifier, formData.value) @@ -251,6 +277,8 @@ async function save() { text: e.toString(), type: 'error', }) + } finally { + isSaving.value = false } } @@ -259,6 +287,8 @@ async function applyPeerDefaults() { return; // do nothing for new interfaces } + if (isApplyingDefaults.value) return + isApplyingDefaults.value = true try { await interfaces.ApplyPeerDefaults(selectedInterface.value.Identifier, formData.value) @@ -276,10 +306,14 @@ async function applyPeerDefaults() { text: e.toString(), type: 'error', }) + } finally { + isApplyingDefaults.value = false } } async function del() { + if (isDeleting.value) return + isDeleting.value = true try { await interfaces.DeleteInterface(selectedInterface.value.Identifier) close() @@ -290,6 +324,8 @@ async function del() { text: e.toString(), type: 'error', }) + } finally { + isDeleting.value = false } } @@ -314,13 +350,22 @@ async function del() { -
- - +
+
+ + +
+
+ + + {{ $t('modals.interface-edit.backend.invalid-label') }} +
@@ -385,12 +430,14 @@ async function del() {
-
+
+
+
-
+
@@ -530,16 +577,25 @@ async function del() {

- +
diff --git a/frontend/src/components/PeerEditModal.vue b/frontend/src/components/PeerEditModal.vue index 554de5a..7c50edf 100644 --- a/frontend/src/components/PeerEditModal.vue +++ b/frontend/src/components/PeerEditModal.vue @@ -73,6 +73,8 @@ const currentTags = ref({ DnsSearch: "" }) const formData = ref(freshPeer()) +const isSaving = ref(false) +const isDeleting = ref(false) // functions @@ -270,6 +272,8 @@ function handleChangeDnsSearch(tags) { } async function save() { + if (isSaving.value) return + isSaving.value = true try { if (props.peerId !== '#NEW#') { await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value) @@ -278,26 +282,30 @@ async function save() { } close() } catch (e) { - // console.log(e) notify({ title: "Failed to save peer!", text: e.toString(), type: 'error', }) + } finally { + isSaving.value = false } } async function del() { + if (isDeleting.value) return + isDeleting.value = true try { await peers.DeletePeer(selectedPeer.value.Identifier) close() } catch (e) { - // console.log(e) notify({ title: "Failed to delete peer!", text: e.toString(), type: 'error', }) + } finally { + isDeleting.value = false } } @@ -470,10 +478,15 @@ async function del() { diff --git a/frontend/src/components/PeerMultiCreateModal.vue b/frontend/src/components/PeerMultiCreateModal.vue index f5a2c87..bc99432 100644 --- a/frontend/src/components/PeerMultiCreateModal.vue +++ b/frontend/src/components/PeerMultiCreateModal.vue @@ -38,6 +38,7 @@ function freshForm() { const currentTag = ref("") const formData = ref(freshForm()) +const isSaving = ref(false) const title = computed(() => { if (!props.visible) { @@ -60,12 +61,15 @@ function handleChangeUserIdentifiers(tags) { } async function save() { + if (isSaving.value) return + isSaving.value = true if (formData.value.Identifiers.length === 0) { notify({ title: "Missing Identifiers", text: "At least one identifier is required to create a new peer.", type: 'error', }) + isSaving.value = false return } @@ -79,6 +83,8 @@ async function save() { text: e.toString(), type: 'error', }) + } finally { + isSaving.value = false } } @@ -108,7 +114,10 @@ async function save() { diff --git a/frontend/src/components/UserEditModal.vue b/frontend/src/components/UserEditModal.vue index 6a4a7bc..340dfe2 100644 --- a/frontend/src/components/UserEditModal.vue +++ b/frontend/src/components/UserEditModal.vue @@ -34,6 +34,8 @@ const title = computed(() => { }) const formData = ref(freshUser()) +const isSaving = ref(false) +const isDeleting = ref(false) const passwordWeak = computed(() => { return formData.value.Password && formData.value.Password.length > 0 && formData.value.Password.length < settings.Setting('MinPasswordLength') @@ -89,6 +91,8 @@ function close() { } async function save() { + if (isSaving.value) return + isSaving.value = true try { if (props.userId!=='#NEW#') { await users.UpdateUser(selectedUser.value.Identifier, formData.value) @@ -102,10 +106,14 @@ async function save() { text: e.toString(), type: 'error', }) + } finally { + isSaving.value = false } } async function del() { + if (isDeleting.value) return + isDeleting.value = true try { await users.DeleteUser(selectedUser.value.Identifier) close() @@ -115,6 +123,8 @@ async function del() { text: e.toString(), type: 'error', }) + } finally { + isDeleting.value = false } } @@ -193,9 +203,15 @@ async function del() { diff --git a/frontend/src/components/UserPeerEditModal.vue b/frontend/src/components/UserPeerEditModal.vue index 7594d7b..15f2f83 100644 --- a/frontend/src/components/UserPeerEditModal.vue +++ b/frontend/src/components/UserPeerEditModal.vue @@ -55,6 +55,8 @@ const title = computed(() => { }) const formData = ref(freshPeer()) +const isSaving = ref(false) +const isDeleting = ref(false) // functions @@ -163,6 +165,8 @@ function close() { } async function save() { + if (isSaving.value) return + isSaving.value = true try { if (props.peerId !== '#NEW#') { await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value) @@ -171,26 +175,30 @@ async function save() { } close() } catch (e) { - // console.log(e) notify({ title: "Failed to save peer!", text: e.toString(), type: 'error', }) + } finally { + isSaving.value = false } } async function del() { + if (isDeleting.value) return + isDeleting.value = true try { await peers.DeletePeer(selectedPeer.value.Identifier) close() } catch (e) { - // console.log(e) notify({ title: "Failed to delete peer!", text: e.toString(), type: 'error', }) + } finally { + isDeleting.value = false } } @@ -283,10 +291,15 @@ async function del() { diff --git a/frontend/src/helpers/models.js b/frontend/src/helpers/models.js index 8f8683e..6e1e52b 100644 --- a/frontend/src/helpers/models.js +++ b/frontend/src/helpers/models.js @@ -5,6 +5,7 @@ export function freshInterface() { DisplayName: "", Identifier: "", Mode: "server", + Backend: "local", PublicKey: "", PrivateKey: "", diff --git a/frontend/src/lang/translations/de.json b/frontend/src/lang/translations/de.json index 859c70e..a0b200c 100644 --- a/frontend/src/lang/translations/de.json +++ b/frontend/src/lang/translations/de.json @@ -102,7 +102,9 @@ }, "interface": { "headline": "Schnittstellenstatus für", - "mode": "Modus", + "backend": "Backend", + "unknown-backend": "Unbekannt", + "wrong-backend": "Ungültiges Backend, das lokale WireGuard Backend wird stattdessen verwendet!", "key": "Öffentlicher Schlüssel", "endpoint": "Öffentlicher Endpunkt", "port": "Port", @@ -357,6 +359,11 @@ "client": "Client-Modus", "any": "Unbekannter Modus" }, + "backend": { + "label": "Schnittstellenbackend", + "invalid-label": "Ursprüngliches Backend ist ungültig, das lokale WireGuard Backend wird stattdessen verwendet!", + "local": "Lokales WireGuard Backend" + }, "display-name": { "label": "Anzeigename", "placeholder": "Der beschreibende Name für die Schnittstelle" diff --git a/frontend/src/lang/translations/en.json b/frontend/src/lang/translations/en.json index 57a129a..af795fc 100644 --- a/frontend/src/lang/translations/en.json +++ b/frontend/src/lang/translations/en.json @@ -102,7 +102,9 @@ }, "interface": { "headline": "Interface status for", - "mode": "mode", + "backend": "Backend", + "unknown-backend": "Unknown", + "wrong-backend": "Invalid backend, using local WireGuard backend instead!", "key": "Public Key", "endpoint": "Public Endpoint", "port": "Listening Port", @@ -357,6 +359,11 @@ "client": "Client Mode", "any": "Unknown Mode" }, + "backend": { + "label": "Interface Backend", + "invalid-label": "Original backend is no longer available, using local WireGuard backend instead!", + "local": "Local WireGuard Backend" + }, "display-name": { "label": "Display Name", "placeholder": "The descriptive name for the interface" diff --git a/frontend/src/lang/translations/fr.json b/frontend/src/lang/translations/fr.json index f5b165c..951ab22 100644 --- a/frontend/src/lang/translations/fr.json +++ b/frontend/src/lang/translations/fr.json @@ -99,7 +99,7 @@ }, "interface": { "headline": "État de l'interface pour", - "mode": "mode", + "backend": "backend", "key": "Clé publique", "endpoint": "Point de terminaison public", "port": "Port d'écoute", diff --git a/frontend/src/lang/translations/ko.json b/frontend/src/lang/translations/ko.json index 8b87d1b..6e65e06 100644 --- a/frontend/src/lang/translations/ko.json +++ b/frontend/src/lang/translations/ko.json @@ -100,7 +100,7 @@ }, "interface": { "headline": "인터페이스 상태:", - "mode": "모드", + "backend": "백엔드", "key": "공개 키", "endpoint": "공개 엔드포인트", "port": "수신 포트", diff --git a/frontend/src/lang/translations/pt.json b/frontend/src/lang/translations/pt.json index a895400..126037e 100644 --- a/frontend/src/lang/translations/pt.json +++ b/frontend/src/lang/translations/pt.json @@ -101,7 +101,7 @@ }, "interface": { "headline": "Status da interface para", - "mode": "modo", + "mode": "backend", "key": "Chave Pública", "endpoint": "Endpoint Público", "port": "Porta de Escuta", diff --git a/frontend/src/lang/translations/ru.json b/frontend/src/lang/translations/ru.json index 6df8383..a88158a 100644 --- a/frontend/src/lang/translations/ru.json +++ b/frontend/src/lang/translations/ru.json @@ -99,7 +99,7 @@ }, "interface": { "headline": "Статус интерфейса для", - "mode": "режим", + "backend": "бэкэнд", "key": "Публичный ключ", "endpoint": "Публичная конечная точка", "port": "Порт прослушивания", diff --git a/frontend/src/lang/translations/uk.json b/frontend/src/lang/translations/uk.json index 7647528..4574ad4 100644 --- a/frontend/src/lang/translations/uk.json +++ b/frontend/src/lang/translations/uk.json @@ -99,7 +99,7 @@ }, "interface": { "headline": "Статус інтерфейсу для", - "mode": "режим", + "backend": "бекенд", "key": "Публічний ключ", "endpoint": "Публічна кінцева точка", "port": "Порт прослуховування", diff --git a/frontend/src/lang/translations/vi.json b/frontend/src/lang/translations/vi.json index 7e90dd9..722918f 100644 --- a/frontend/src/lang/translations/vi.json +++ b/frontend/src/lang/translations/vi.json @@ -98,7 +98,7 @@ }, "interface": { "headline": "Trạng thái giao diện cho", - "mode": "chế độ", + "backend": "phần sau", "key": "Khóa Công khai", "endpoint": "Điểm cuối Công khai", "port": "Cổng Nghe", diff --git a/frontend/src/lang/translations/zh.json b/frontend/src/lang/translations/zh.json index cf1d715..3b5b64e 100644 --- a/frontend/src/lang/translations/zh.json +++ b/frontend/src/lang/translations/zh.json @@ -98,7 +98,7 @@ }, "interface": { "headline": "接口状态", - "mode": "模式", + "backend": "后端", "key": "公钥", "endpoint": "公开节点", "port": "监听端口", diff --git a/frontend/src/views/InterfaceView.vue b/frontend/src/views/InterfaceView.vue index 1cdb9fb..b28cf5f 100644 --- a/frontend/src/views/InterfaceView.vue +++ b/frontend/src/views/InterfaceView.vue @@ -5,17 +5,20 @@ import PeerMultiCreateModal from "../components/PeerMultiCreateModal.vue"; import InterfaceEditModal from "../components/InterfaceEditModal.vue"; import InterfaceViewModal from "../components/InterfaceViewModal.vue"; -import {onMounted, ref} from "vue"; +import {computed, onMounted, ref} from "vue"; import {peerStore} from "@/stores/peers"; import {interfaceStore} from "@/stores/interfaces"; import {notify} from "@kyvg/vue3-notification"; import {settingsStore} from "@/stores/settings"; import {humanFileSize} from '@/helpers/utils'; +import {useI18n} from "vue-i18n"; const settings = settingsStore() const interfaces = interfaceStore() const peers = peerStore() +const { t } = useI18n() + const viewedPeerId = ref("") const editPeerId = ref("") const multiCreatePeerId = ref("") @@ -45,6 +48,33 @@ function calculateInterfaceName(id, name) { return result } +const calculateBackendName = computed(() => { + let backendId = interfaces.GetSelected.Backend + + let backendName = t('interfaces.interface.unknown-backend') + let availableBackends = settings.Setting('AvailableBackends') || [] + availableBackends.forEach(backend => { + if (backend.Id === backendId) { + backendName = backend.Id === 'local' ? t(backend.Name) : backend.Name + } + }) + return backendName +}) + +const isBackendValid = computed(() => { + let backendId = interfaces.GetSelected.Backend + + let valid = false + let availableBackends = settings.Setting('AvailableBackends') || [] + availableBackends.forEach(backend => { + if (backend.Id === backendId) { + valid = true + } + }) + return valid +}) + + async function download() { await interfaces.LoadInterfaceConfig(interfaces.GetSelected.Identifier) @@ -141,7 +171,7 @@ onMounted(async () => {
- {{ $t('interfaces.interface.headline') }} {{interfaces.GetSelected.Identifier}} ({{interfaces.GetSelected.Mode}} {{ $t('interfaces.interface.mode') }}) + {{ $t('interfaces.interface.headline') }} {{interfaces.GetSelected.Identifier}} ({{ $t('modals.interface-edit.mode.' + interfaces.GetSelected.Mode )}} | {{ $t('interfaces.interface.backend') + ": " + calculateBackendName }})
diff --git a/internal/adapters/wgcontroller/local.go b/internal/adapters/wgcontroller/local.go new file mode 100644 index 0000000..7f2e7fa --- /dev/null +++ b/internal/adapters/wgcontroller/local.go @@ -0,0 +1,864 @@ +package wgcontroller + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "strings" + "time" + + probing "github.com/prometheus-community/pro-bing" + "github.com/vishvananda/netlink" + "golang.org/x/sys/unix" + "golang.zx2c4.com/wireguard/wgctrl" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/h44z/wg-portal/internal" + "github.com/h44z/wg-portal/internal/config" + "github.com/h44z/wg-portal/internal/domain" + "github.com/h44z/wg-portal/internal/lowlevel" +) + +// region dependencies + +// WgCtrlRepo is used to control local WireGuard devices via the wgctrl-go library. +type WgCtrlRepo interface { + io.Closer + Devices() ([]*wgtypes.Device, error) + Device(name string) (*wgtypes.Device, error) + ConfigureDevice(name string, cfg wgtypes.Config) error +} + +// A NetlinkClient is a type which can control a netlink device. +type NetlinkClient interface { + LinkAdd(link netlink.Link) error + LinkDel(link netlink.Link) error + LinkByName(name string) (netlink.Link, error) + LinkSetUp(link netlink.Link) error + LinkSetDown(link netlink.Link) error + LinkSetMTU(link netlink.Link, mtu int) error + AddrReplace(link netlink.Link, addr *netlink.Addr) error + AddrAdd(link netlink.Link, addr *netlink.Addr) error + AddrList(link netlink.Link) ([]netlink.Addr, error) + AddrDel(link netlink.Link, addr *netlink.Addr) error + RouteAdd(route *netlink.Route) error + RouteDel(route *netlink.Route) error + RouteReplace(route *netlink.Route) error + RouteList(link netlink.Link, family int) ([]netlink.Route, error) + RouteListFiltered(family int, filter *netlink.Route, filterMask uint64) ([]netlink.Route, error) + RuleAdd(rule *netlink.Rule) error + RuleDel(rule *netlink.Rule) error + RuleList(family int) ([]netlink.Rule, error) +} + +// endregion dependencies + +type LocalController struct { + cfg *config.Config + + wg WgCtrlRepo + nl NetlinkClient + + shellCmd string + resolvConfIfacePrefix string +} + +// NewLocalController creates a new local controller instance. +// This repository is used to interact with the WireGuard kernel or userspace module. +func NewLocalController(cfg *config.Config) (*LocalController, error) { + wg, err := wgctrl.New() + if err != nil { + return nil, fmt.Errorf("failed to create wgctrl client: %w", err) + } + + nl := &lowlevel.NetlinkManager{} + + repo := &LocalController{ + cfg: cfg, + + wg: wg, + nl: nl, + + shellCmd: "bash", // we only support bash at the moment + resolvConfIfacePrefix: "tun.", // WireGuard interfaces have a tun. prefix in resolvconf + } + + return repo, nil +} + +func (c LocalController) GetId() domain.InterfaceBackend { + return config.LocalBackendName +} + +// region wireguard-related + +func (c LocalController) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) { + devices, err := c.wg.Devices() + if err != nil { + return nil, fmt.Errorf("device list error: %w", err) + } + + interfaces := make([]domain.PhysicalInterface, 0, len(devices)) + for _, device := range devices { + interfaceModel, err := c.convertWireGuardInterface(device) + if err != nil { + return nil, fmt.Errorf("interface convert failed for %s: %w", device.Name, err) + } + interfaces = append(interfaces, interfaceModel) + } + + return interfaces, nil +} + +func (c LocalController) GetInterface(_ context.Context, id domain.InterfaceIdentifier) ( + *domain.PhysicalInterface, + error, +) { + return c.getInterface(id) +} + +func (c LocalController) convertWireGuardInterface(device *wgtypes.Device) (domain.PhysicalInterface, error) { + // read data from wgctrl interface + + iface := domain.PhysicalInterface{ + Identifier: domain.InterfaceIdentifier(device.Name), + KeyPair: domain.KeyPair{ + PrivateKey: device.PrivateKey.String(), + PublicKey: device.PublicKey.String(), + }, + ListenPort: device.ListenPort, + Addresses: nil, + Mtu: 0, + FirewallMark: uint32(device.FirewallMark), + DeviceUp: false, + ImportSource: domain.ControllerTypeLocal, + DeviceType: device.Type.String(), + BytesUpload: 0, + BytesDownload: 0, + } + + // read data from netlink interface + + lowLevelInterface, err := c.nl.LinkByName(device.Name) + if err != nil { + return domain.PhysicalInterface{}, fmt.Errorf("netlink error for %s: %w", device.Name, err) + } + ipAddresses, err := c.nl.AddrList(lowLevelInterface) + if err != nil { + return domain.PhysicalInterface{}, fmt.Errorf("ip read error for %s: %w", device.Name, err) + } + + for _, addr := range ipAddresses { + iface.Addresses = append(iface.Addresses, domain.CidrFromNetlinkAddr(addr)) + } + iface.Mtu = lowLevelInterface.Attrs().MTU + iface.DeviceUp = lowLevelInterface.Attrs().OperState == netlink.OperUnknown // wg only supports unknown + if stats := lowLevelInterface.Attrs().Statistics; stats != nil { + iface.BytesUpload = stats.TxBytes + iface.BytesDownload = stats.RxBytes + } + + return iface, nil +} + +func (c LocalController) GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ( + []domain.PhysicalPeer, + error, +) { + device, err := c.wg.Device(string(deviceId)) + if err != nil { + return nil, fmt.Errorf("device error: %w", err) + } + + peers := make([]domain.PhysicalPeer, 0, len(device.Peers)) + for _, peer := range device.Peers { + peerModel, err := c.convertWireGuardPeer(&peer) + if err != nil { + return nil, fmt.Errorf("peer convert failed for %v: %w", peer.PublicKey, err) + } + peers = append(peers, peerModel) + } + + return peers, nil +} + +func (c LocalController) convertWireGuardPeer(peer *wgtypes.Peer) (domain.PhysicalPeer, error) { + peerModel := domain.PhysicalPeer{ + Identifier: domain.PeerIdentifier(peer.PublicKey.String()), + Endpoint: "", + AllowedIPs: nil, + KeyPair: domain.KeyPair{ + PublicKey: peer.PublicKey.String(), + }, + PresharedKey: "", + PersistentKeepalive: int(peer.PersistentKeepaliveInterval.Seconds()), + LastHandshake: peer.LastHandshakeTime, + ProtocolVersion: peer.ProtocolVersion, + BytesUpload: uint64(peer.ReceiveBytes), + BytesDownload: uint64(peer.TransmitBytes), + ImportSource: domain.ControllerTypeLocal, + } + + // Set local extras - local peers are never disabled in the kernel + peerModel.SetExtras(domain.LocalPeerExtras{ + Disabled: false, + }) + + for _, addr := range peer.AllowedIPs { + peerModel.AllowedIPs = append(peerModel.AllowedIPs, domain.CidrFromIpNet(addr)) + } + if peer.Endpoint != nil { + peerModel.Endpoint = peer.Endpoint.String() + } + if peer.PresharedKey != (wgtypes.Key{}) { + peerModel.PresharedKey = domain.PreSharedKey(peer.PresharedKey.String()) + } + + return peerModel, nil +} + +func (c LocalController) SaveInterface( + _ context.Context, + id domain.InterfaceIdentifier, + updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error), +) error { + physicalInterface, err := c.getOrCreateInterface(id) + if err != nil { + return err + } + + if updateFunc != nil { + physicalInterface, err = updateFunc(physicalInterface) + if err != nil { + return err + } + } + + if err := c.updateLowLevelInterface(physicalInterface); err != nil { + return err + } + if err := c.updateWireGuardInterface(physicalInterface); err != nil { + return err + } + + return nil +} + +func (c LocalController) getOrCreateInterface(id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) { + device, err := c.getInterface(id) + if err == nil { + return device, nil // interface exists + } + if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("device error: %w", err) // unknown error + } + + // create new device + if err := c.createLowLevelInterface(id); err != nil { + return nil, err + } + + device, err = c.getInterface(id) + return device, err +} + +func (c LocalController) getInterface(id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) { + device, err := c.wg.Device(string(id)) + if err != nil { + return nil, err + } + + pi, err := c.convertWireGuardInterface(device) + return &pi, err +} + +func (c LocalController) createLowLevelInterface(id domain.InterfaceIdentifier) error { + link := &netlink.GenericLink{ + LinkAttrs: netlink.LinkAttrs{ + Name: string(id), + }, + LinkType: "wireguard", + } + err := c.nl.LinkAdd(link) + if err != nil { + return fmt.Errorf("link add failed: %w", err) + } + + return nil +} + +func (c LocalController) updateLowLevelInterface(pi *domain.PhysicalInterface) error { + link, err := c.nl.LinkByName(string(pi.Identifier)) + if err != nil { + return err + } + if pi.Mtu != 0 { + if err := c.nl.LinkSetMTU(link, pi.Mtu); err != nil { + return fmt.Errorf("mtu error: %w", err) + } + } + + for _, addr := range pi.Addresses { + err := c.nl.AddrReplace(link, addr.NetlinkAddr()) + if err != nil { + return fmt.Errorf("failed to set ip %s: %w", addr.String(), err) + } + } + + // Remove unwanted IP addresses + rawAddresses, err := c.nl.AddrList(link) + if err != nil { + return fmt.Errorf("failed to fetch interface ips: %w", err) + } + for _, rawAddr := range rawAddresses { + netlinkAddr := domain.CidrFromNetlinkAddr(rawAddr) + remove := true + for _, addr := range pi.Addresses { + if addr == netlinkAddr { + remove = false + break + } + } + + if !remove { + continue + } + + err := c.nl.AddrDel(link, &rawAddr) + if err != nil { + return fmt.Errorf("failed to remove deprecated ip %s: %w", netlinkAddr.String(), err) + } + } + + // Update link state + if pi.DeviceUp { + if err := c.nl.LinkSetUp(link); err != nil { + return fmt.Errorf("failed to bring up device: %w", err) + } + } else { + if err := c.nl.LinkSetDown(link); err != nil { + return fmt.Errorf("failed to bring down device: %w", err) + } + } + + return nil +} + +func (c LocalController) updateWireGuardInterface(pi *domain.PhysicalInterface) error { + pKey, err := wgtypes.NewKey(pi.KeyPair.GetPrivateKeyBytes()) + if err != nil { + return err + } + + var fwMark *int + if pi.FirewallMark != 0 { + intFwMark := int(pi.FirewallMark) + fwMark = &intFwMark + } + err = c.wg.ConfigureDevice(string(pi.Identifier), wgtypes.Config{ + PrivateKey: &pKey, + ListenPort: &pi.ListenPort, + FirewallMark: fwMark, + ReplacePeers: false, + }) + if err != nil { + return err + } + + return nil +} + +func (c LocalController) DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error { + if err := c.deleteLowLevelInterface(id); err != nil { + return err + } + + return nil +} + +func (c LocalController) deleteLowLevelInterface(id domain.InterfaceIdentifier) error { + link, err := c.nl.LinkByName(string(id)) + if err != nil { + var linkNotFoundError netlink.LinkNotFoundError + if errors.As(err, &linkNotFoundError) { + return nil // ignore not found error + } + return fmt.Errorf("unable to find low level interface: %w", err) + } + + err = c.nl.LinkDel(link) + if err != nil { + return fmt.Errorf("failed to delete low level interface: %w", err) + } + + return nil +} + +func (c LocalController) SavePeer( + _ context.Context, + deviceId domain.InterfaceIdentifier, + id domain.PeerIdentifier, + updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error), +) error { + physicalPeer, err := c.getOrCreatePeer(deviceId, id) + if err != nil { + return err + } + + physicalPeer, err = updateFunc(physicalPeer) + if err != nil { + return err + } + + // Check if the peer is disabled by looking at the backend extras + // For local controller, disabled peers should be deleted + if physicalPeer.GetExtras() != nil { + switch extras := physicalPeer.GetExtras().(type) { + case domain.LocalPeerExtras: + if extras.Disabled { + // Delete the peer instead of updating it + return c.deletePeer(deviceId, id) + } + } + } + + if err := c.updatePeer(deviceId, physicalPeer); err != nil { + return err + } + + return nil +} + +func (c LocalController) getOrCreatePeer(deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) ( + *domain.PhysicalPeer, + error, +) { + peer, err := c.getPeer(deviceId, id) + if err == nil { + return peer, nil // peer exists + } + if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("peer error: %w", err) // unknown error + } + + // create new peer + err = c.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ + Peers: []wgtypes.PeerConfig{ + { + PublicKey: id.ToPublicKey(), + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("peer create error for %s: %w", id.ToPublicKey(), err) + } + + peer, err = c.getPeer(deviceId, id) + if err != nil { + return nil, fmt.Errorf("peer error after create: %w", err) + } + return peer, nil +} + +func (c LocalController) getPeer(deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) ( + *domain.PhysicalPeer, + error, +) { + if !id.IsPublicKey() { + return nil, errors.New("invalid public key") + } + + device, err := c.wg.Device(string(deviceId)) + if err != nil { + return nil, err + } + + publicKey := id.ToPublicKey() + for _, peer := range device.Peers { + if peer.PublicKey != publicKey { + continue + } + + peerModel, err := c.convertWireGuardPeer(&peer) + return &peerModel, err + } + + return nil, os.ErrNotExist +} + +func (c LocalController) updatePeer(deviceId domain.InterfaceIdentifier, pp *domain.PhysicalPeer) error { + cfg := wgtypes.PeerConfig{ + PublicKey: pp.GetPublicKey(), + Remove: false, + UpdateOnly: true, + PresharedKey: pp.GetPresharedKey(), + Endpoint: pp.GetEndpointAddress(), + PersistentKeepaliveInterval: pp.GetPersistentKeepaliveTime(), + ReplaceAllowedIPs: true, + AllowedIPs: pp.GetAllowedIPs(), + } + + err := c.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}}) + if err != nil { + return err + } + + return nil +} + +func (c LocalController) DeletePeer( + _ context.Context, + deviceId domain.InterfaceIdentifier, + id domain.PeerIdentifier, +) error { + if !id.IsPublicKey() { + return errors.New("invalid public key") + } + + err := c.deletePeer(deviceId, id) + if err != nil { + return err + } + + return nil +} + +func (c LocalController) deletePeer(deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error { + cfg := wgtypes.PeerConfig{ + PublicKey: id.ToPublicKey(), + Remove: true, + } + + err := c.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}}) + if err != nil { + return err + } + + return nil +} + +// endregion wireguard-related + +// region wg-quick-related + +func (c LocalController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error { + if hookCmd == "" { + return nil + } + + slog.Debug("executing interface hook", "interface", id, "hook", hookCmd) + err := c.exec(hookCmd, id) + if err != nil { + return fmt.Errorf("failed to exec hook: %w", err) + } + + return nil +} + +func (c LocalController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error { + if dnsStr == "" && dnsSearchStr == "" { + return nil + } + + dnsServers := internal.SliceString(dnsStr) + dnsSearchDomains := internal.SliceString(dnsSearchStr) + + dnsCommand := "resolvconf -a %resPref%i -m 0 -x" + dnsCommandInput := make([]string, 0, len(dnsServers)+len(dnsSearchDomains)) + + for _, dnsServer := range dnsServers { + dnsCommandInput = append(dnsCommandInput, fmt.Sprintf("nameserver %s", dnsServer)) + } + for _, searchDomain := range dnsSearchDomains { + dnsCommandInput = append(dnsCommandInput, fmt.Sprintf("search %s", searchDomain)) + } + + err := c.exec(dnsCommand, id, dnsCommandInput...) + if err != nil { + return fmt.Errorf( + "failed to set dns settings (is resolvconf available?, for systemd create this symlink: ln -s /usr/bin/resolvectl /usr/local/bin/resolvconf): %w", + err, + ) + } + + return nil +} + +func (c LocalController) UnsetDNS(id domain.InterfaceIdentifier) error { + dnsCommand := "resolvconf -d %resPref%i -f" + + err := c.exec(dnsCommand, id) + if err != nil { + return fmt.Errorf("failed to unset dns settings: %w", err) + } + + return nil +} + +func (c LocalController) replaceCommandPlaceHolders(command string, interfaceId domain.InterfaceIdentifier) string { + command = strings.ReplaceAll(command, "%resPref", c.resolvConfIfacePrefix) + return strings.ReplaceAll(command, "%i", string(interfaceId)) +} + +func (c LocalController) exec(command string, interfaceId domain.InterfaceIdentifier, stdin ...string) error { + commandWithInterfaceName := c.replaceCommandPlaceHolders(command, interfaceId) + cmd := exec.Command(c.shellCmd, "-ce", commandWithInterfaceName) + if len(stdin) > 0 { + b := &bytes.Buffer{} + for _, ln := range stdin { + if _, err := fmt.Fprint(b, ln); err != nil { + return err + } + } + cmd.Stdin = b + } + out, err := cmd.CombinedOutput() // execute and wait for output + if err != nil { + return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err) + } + slog.Debug("executed shell command", + "command", commandWithInterfaceName, + "output", string(out)) + return nil +} + +// endregion wg-quick-related + +// region routing-related + +func (c LocalController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error { + // update fwmark rules + if err := c.setFwMarkRules(rules); err != nil { + return err + } + + // update main rule + if err := c.setMainRule(rules); err != nil { + return err + } + + // cleanup old main rules + if err := c.cleanupMainRule(rules); err != nil { + return err + } + + return nil +} + +func (c LocalController) setFwMarkRules(rules []domain.RouteRule) error { + for _, rule := range rules { + existingRules, err := c.nl.RuleList(int(rule.IpFamily)) + if err != nil { + return fmt.Errorf("failed to get existing rules for family %s: %w", rule.IpFamily, err) + } + + ruleExists := false + for _, existingRule := range existingRules { + if rule.FwMark == existingRule.Mark && rule.Table == existingRule.Table { + ruleExists = true + break + } + } + + if ruleExists { + continue // rule already exists, no need to recreate it + } + + // create a missing rule + if err := c.nl.RuleAdd(&netlink.Rule{ + Family: int(rule.IpFamily), + Table: rule.Table, + Mark: rule.FwMark, + Invert: true, + SuppressIfgroup: -1, + SuppressPrefixlen: -1, + Priority: c.getRulePriority(existingRules), + Mask: nil, + Goto: -1, + Flow: -1, + }); err != nil { + return fmt.Errorf("failed to setup %s rule for fwmark %d and table %d: %w", + rule.IpFamily, rule.FwMark, rule.Table, err) + } + } + return nil +} + +func (c LocalController) getRulePriority(existingRules []netlink.Rule) int { + prio := 32700 // linux main rule has a priority of 32766 + for { + isFresh := true + for _, existingRule := range existingRules { + if existingRule.Priority == prio { + isFresh = false + break + } + } + if isFresh { + break + } else { + prio-- + } + } + return prio +} + +func (c LocalController) setMainRule(rules []domain.RouteRule) error { + var family domain.IpFamily + shouldHaveMainRule := false + for _, rule := range rules { + family = rule.IpFamily + if rule.HasDefault == true { + shouldHaveMainRule = true + break + } + } + if !shouldHaveMainRule { + return nil + } + + existingRules, err := c.nl.RuleList(int(family)) + if err != nil { + return fmt.Errorf("failed to get existing rules for family %s: %w", family, err) + } + + ruleExists := false + for _, existingRule := range existingRules { + if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 { + ruleExists = true + break + } + } + + if ruleExists { + return nil // rule already exists, skip re-creation + } + + if err := c.nl.RuleAdd(&netlink.Rule{ + Family: int(family), + Table: unix.RT_TABLE_MAIN, + SuppressIfgroup: -1, + SuppressPrefixlen: 0, + Priority: c.getMainRulePriority(existingRules), + Mark: 0, + Mask: nil, + Goto: -1, + Flow: -1, + }); err != nil { + return fmt.Errorf("failed to setup rule for main table: %w", err) + } + + return nil +} + +func (c LocalController) getMainRulePriority(existingRules []netlink.Rule) int { + priority := c.cfg.Advanced.RulePrioOffset + for { + isFresh := true + for _, existingRule := range existingRules { + if existingRule.Priority == priority { + isFresh = false + break + } + } + if isFresh { + break + } else { + priority++ + } + } + return priority +} + +func (c LocalController) cleanupMainRule(rules []domain.RouteRule) error { + var family domain.IpFamily + for _, rule := range rules { + family = rule.IpFamily + break + } + + existingRules, err := c.nl.RuleList(int(family)) + if err != nil { + return fmt.Errorf("failed to get existing rules for family %s: %w", family, err) + } + + shouldHaveMainRule := false + for _, rule := range rules { + if rule.HasDefault == true { + shouldHaveMainRule = true + break + } + } + + mainRules := 0 + for _, existingRule := range existingRules { + if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 { + mainRules++ + } + } + + removalCount := 0 + if mainRules > 1 { + removalCount = mainRules - 1 // we only want one single rule + } + if !shouldHaveMainRule { + removalCount = mainRules + } + + for _, existingRule := range existingRules { + if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 { + if removalCount > 0 { + existingRule.Family = int(family) // set family, somehow the RuleList method does not populate the family field + if err := c.nl.RuleDel(&existingRule); err != nil { + return fmt.Errorf("failed to delete main rule: %w", err) + } + removalCount-- + } + } + } + + return nil +} + +func (c LocalController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error { + // TODO implement me + panic("implement me") +} + +// endregion routing-related + +// region statistics-related + +func (c LocalController) PingAddresses( + ctx context.Context, + addr string, +) (*domain.PingerResult, error) { + pinger, err := probing.NewPinger(addr) + if err != nil { + return nil, fmt.Errorf("failed to instantiate pinger for %s: %w", addr, err) + } + + checkCount := 1 + pinger.SetPrivileged(!c.cfg.Statistics.PingUnprivileged) + pinger.Count = checkCount + pinger.Timeout = 2 * time.Second + err = pinger.RunWithContext(ctx) // Blocks until finished. + if err != nil { + return nil, fmt.Errorf("failed to ping %s: %w", addr, err) + } + + stats := pinger.Statistics() + + return &domain.PingerResult{ + PacketsRecv: stats.PacketsRecv, + PacketsSent: stats.PacketsSent, + Rtts: stats.Rtts, + }, nil +} + +// endregion statistics-related diff --git a/internal/adapters/wgcontroller/mikrotik.go b/internal/adapters/wgcontroller/mikrotik.go new file mode 100644 index 0000000..8498d34 --- /dev/null +++ b/internal/adapters/wgcontroller/mikrotik.go @@ -0,0 +1,829 @@ +package wgcontroller + +import ( + "context" + "fmt" + "slices" + "strconv" + "strings" + "sync" + "time" + + "log/slog" + + "github.com/h44z/wg-portal/internal/config" + "github.com/h44z/wg-portal/internal/domain" + "github.com/h44z/wg-portal/internal/lowlevel" +) + +type MikrotikController struct { + coreCfg *config.Config + cfg *config.BackendMikrotik + + client *lowlevel.MikrotikApiClient + + // Add mutexes to prevent race conditions + interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*sync.Mutex + peerMutexes sync.Map // map[domain.PeerIdentifier]*sync.Mutex +} + +func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikController, error) { + client, err := lowlevel.NewMikrotikApiClient(coreCfg, cfg) + if err != nil { + return nil, fmt.Errorf("failed to create Mikrotik API client: %w", err) + } + + return &MikrotikController{ + coreCfg: coreCfg, + cfg: cfg, + + client: client, + + interfaceMutexes: sync.Map{}, + peerMutexes: sync.Map{}, + }, nil +} + +func (c *MikrotikController) GetId() domain.InterfaceBackend { + return domain.InterfaceBackend(c.cfg.Id) +} + +// getInterfaceMutex returns a mutex for the given interface to prevent concurrent modifications +func (c *MikrotikController) getInterfaceMutex(id domain.InterfaceIdentifier) *sync.Mutex { + mutex, _ := c.interfaceMutexes.LoadOrStore(id, &sync.Mutex{}) + return mutex.(*sync.Mutex) +} + +// getPeerMutex returns a mutex for the given peer to prevent concurrent modifications +func (c *MikrotikController) getPeerMutex(id domain.PeerIdentifier) *sync.Mutex { + mutex, _ := c.peerMutexes.LoadOrStore(id, &sync.Mutex{}) + return mutex.(*sync.Mutex) +} + +// region wireguard-related + +func (c *MikrotikController) GetInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) { + wgReply := c.client.Query(ctx, "/interface/wireguard", &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + ".id", "name", "public-key", "private-key", "listen-port", "mtu", "disabled", "running", "comment", + }, + }) + if wgReply.Status != lowlevel.MikrotikApiStatusOk { + return nil, fmt.Errorf("failed to query interfaces: %v", wgReply.Error) + } + + // Parallelize loading of interface details to speed up overall latency. + // Use a bounded semaphore to avoid overloading the MikroTik device. + maxConcurrent := c.cfg.GetConcurrency() + sem := make(chan struct{}, maxConcurrent) + + interfaces := make([]domain.PhysicalInterface, 0, len(wgReply.Data)) + var mu sync.Mutex + var wgWait sync.WaitGroup + var firstErr error + ctx2, cancel := context.WithCancel(ctx) + defer cancel() + + for _, wgObj := range wgReply.Data { + wgWait.Add(1) + sem <- struct{}{} // block if more than maxConcurrent requests are processing + go func(wg lowlevel.GenericJsonObject) { + defer wgWait.Done() + defer func() { <-sem }() // read from the semaphore and make space for the next entry + if firstErr != nil { + return + } + pi, err := c.loadInterfaceData(ctx2, wg) + if err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + cancel() + } + mu.Unlock() + return + } + mu.Lock() + interfaces = append(interfaces, *pi) + mu.Unlock() + }(wgObj) + } + + wgWait.Wait() + if firstErr != nil { + return nil, firstErr + } + + return interfaces, nil +} + +func (c *MikrotikController) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) ( + *domain.PhysicalInterface, + error, +) { + wgReply := c.client.Query(ctx, "/interface/wireguard", &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + ".id", "name", "public-key", "private-key", "listen-port", "mtu", "disabled", "running", + }, + Filters: map[string]string{ + "name": string(id), + }, + }) + if wgReply.Status != lowlevel.MikrotikApiStatusOk { + return nil, fmt.Errorf("failed to query interface %s: %v", id, wgReply.Error) + } + + if len(wgReply.Data) == 0 { + return nil, fmt.Errorf("interface %s not found", id) + } + + return c.loadInterfaceData(ctx, wgReply.Data[0]) +} + +func (c *MikrotikController) loadInterfaceData( + ctx context.Context, + wireGuardObj lowlevel.GenericJsonObject, +) (*domain.PhysicalInterface, error) { + deviceId := wireGuardObj.GetString(".id") + deviceName := wireGuardObj.GetString("name") + ifaceReply := c.client.Get(ctx, "/interface/"+deviceId, &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + "name", "rx-byte", "tx-byte", + }, + }) + if ifaceReply.Status != lowlevel.MikrotikApiStatusOk { + return nil, fmt.Errorf("failed to query interface %s: %v", deviceId, ifaceReply.Error) + } + + ipv4, ipv6, err := c.loadIpAddresses(ctx, deviceName) + if err != nil { + return nil, fmt.Errorf("failed to query IP addresses for interface %s: %v", deviceId, err) + } + addresses := c.convertIpAddresses(ipv4, ipv6) + + interfaceModel, err := c.convertWireGuardInterface(wireGuardObj, ifaceReply.Data, addresses) + if err != nil { + return nil, fmt.Errorf("interface convert failed for %s: %w", deviceName, err) + } + return &interfaceModel, nil +} + +func (c *MikrotikController) loadIpAddresses( + ctx context.Context, + deviceName string, +) (ipv4 []lowlevel.GenericJsonObject, ipv6 []lowlevel.GenericJsonObject, err error) { + // Query IPv4 and IPv6 addresses in parallel to reduce latency. + var ( + v4 []lowlevel.GenericJsonObject + v6 []lowlevel.GenericJsonObject + v4Err error + v6Err error + wg sync.WaitGroup + ) + wg.Add(2) + + go func() { + defer wg.Done() + addrV4Reply := c.client.Query(ctx, "/ip/address", &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + ".id", "address", "network", + }, + Filters: map[string]string{ + "interface": deviceName, + "dynamic": "false", // we only want static addresses + "disabled": "false", // we only want addresses that are not disabled + }, + }) + if addrV4Reply.Status != lowlevel.MikrotikApiStatusOk { + v4Err = fmt.Errorf("failed to query IPv4 addresses for interface %s: %v", deviceName, addrV4Reply.Error) + return + } + v4 = addrV4Reply.Data + }() + + go func() { + defer wg.Done() + addrV6Reply := c.client.Query(ctx, "/ipv6/address", &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + ".id", "address", "network", + }, + Filters: map[string]string{ + "interface": deviceName, + "dynamic": "false", // we only want static addresses + "disabled": "false", // we only want addresses that are not disabled + }, + }) + if addrV6Reply.Status != lowlevel.MikrotikApiStatusOk { + v6Err = fmt.Errorf("failed to query IPv6 addresses for interface %s: %v", deviceName, addrV6Reply.Error) + return + } + v6 = addrV6Reply.Data + }() + + wg.Wait() + if v4Err != nil { + return nil, nil, v4Err + } + if v6Err != nil { + return nil, nil, v6Err + } + + return v4, v6, nil +} + +func (c *MikrotikController) convertIpAddresses( + ipv4, ipv6 []lowlevel.GenericJsonObject, +) []domain.Cidr { + addresses := make([]domain.Cidr, 0, len(ipv4)+len(ipv6)) + for _, addr := range append(ipv4, ipv6...) { + addrStr := addr.GetString("address") + if addrStr == "" { + continue + } + cidr, err := domain.CidrFromString(addrStr) + if err != nil { + continue + } + + addresses = append(addresses, cidr) + } + + return addresses +} + +func (c *MikrotikController) convertWireGuardInterface( + wg, iface lowlevel.GenericJsonObject, + addresses []domain.Cidr, +) ( + domain.PhysicalInterface, + error, +) { + pi := domain.PhysicalInterface{ + Identifier: domain.InterfaceIdentifier(wg.GetString("name")), + KeyPair: domain.KeyPair{ + PrivateKey: wg.GetString("private-key"), + PublicKey: wg.GetString("public-key"), + }, + ListenPort: wg.GetInt("listen-port"), + Addresses: addresses, + Mtu: wg.GetInt("mtu"), + FirewallMark: 0, + DeviceUp: wg.GetBool("running"), + ImportSource: domain.ControllerTypeMikrotik, + DeviceType: domain.ControllerTypeMikrotik, + BytesUpload: uint64(iface.GetInt("tx-byte")), + BytesDownload: uint64(iface.GetInt("rx-byte")), + } + + pi.SetExtras(domain.MikrotikInterfaceExtras{ + Id: wg.GetString(".id"), + Comment: wg.GetString("comment"), + Disabled: wg.GetBool("disabled"), + }) + + return pi, nil +} + +func (c *MikrotikController) GetPeers(ctx context.Context, deviceId domain.InterfaceIdentifier) ( + []domain.PhysicalPeer, + error, +) { + wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + ".id", "name", "allowed-address", "client-address", "client-endpoint", "client-keepalive", "comment", + "current-endpoint-address", "current-endpoint-port", "last-handshake", "persistent-keepalive", + "public-key", "private-key", "preshared-key", "mtu", "disabled", "rx", "tx", "responder", "client-dns", + }, + Filters: map[string]string{ + "interface": string(deviceId), + }, + }) + if wgReply.Status != lowlevel.MikrotikApiStatusOk { + return nil, fmt.Errorf("failed to query peers for %s: %v", deviceId, wgReply.Error) + } + + if len(wgReply.Data) == 0 { + return nil, nil + } + + peers := make([]domain.PhysicalPeer, 0, len(wgReply.Data)) + for _, peer := range wgReply.Data { + peerModel, err := c.convertWireGuardPeer(peer) + if err != nil { + return nil, fmt.Errorf("peer convert failed for %v: %w", peer.GetString("name"), err) + } + peers = append(peers, peerModel) + } + + return peers, nil +} + +func (c *MikrotikController) convertWireGuardPeer(peer lowlevel.GenericJsonObject) ( + domain.PhysicalPeer, + error, +) { + keepAliveSeconds := 0 + duration, err := time.ParseDuration(peer.GetString("persistent-keepalive")) + if err == nil { + keepAliveSeconds = int(duration.Seconds()) + } + + currentEndpoint := "" + if peer.GetString("current-endpoint-address") != "" && peer.GetString("current-endpoint-port") != "" { + currentEndpoint = peer.GetString("current-endpoint-address") + ":" + peer.GetString("current-endpoint-port") + } + + lastHandshakeTime := time.Time{} + if peer.GetString("last-handshake") != "" { + relDuration, err := time.ParseDuration(peer.GetString("last-handshake")) + if err == nil { + lastHandshakeTime = time.Now().Add(-relDuration) + } + } + + allowedAddresses, _ := domain.CidrsFromString(peer.GetString("allowed-address")) + + clientKeepAliveSeconds := 0 + duration, err = time.ParseDuration(peer.GetString("client-keepalive")) + if err == nil { + clientKeepAliveSeconds = int(duration.Seconds()) + } + + peerModel := domain.PhysicalPeer{ + Identifier: domain.PeerIdentifier(peer.GetString("public-key")), + Endpoint: currentEndpoint, + AllowedIPs: allowedAddresses, + KeyPair: domain.KeyPair{ + PublicKey: peer.GetString("public-key"), + PrivateKey: peer.GetString("private-key"), + }, + PresharedKey: domain.PreSharedKey(peer.GetString("preshared-key")), + PersistentKeepalive: keepAliveSeconds, + LastHandshake: lastHandshakeTime, + ProtocolVersion: 0, // Mikrotik does not support protocol versioning, so we set it to 0 + BytesUpload: uint64(peer.GetInt("rx")), + BytesDownload: uint64(peer.GetInt("tx")), + ImportSource: domain.ControllerTypeMikrotik, + } + + peerModel.SetExtras(domain.MikrotikPeerExtras{ + Id: peer.GetString(".id"), + Name: peer.GetString("name"), + Comment: peer.GetString("comment"), + IsResponder: peer.GetBool("responder"), + Disabled: peer.GetBool("disabled"), + ClientEndpoint: peer.GetString("client-endpoint"), + ClientAddress: peer.GetString("client-address"), + ClientDns: peer.GetString("client-dns"), + ClientKeepalive: clientKeepAliveSeconds, + }) + + return peerModel, nil +} + +func (c *MikrotikController) SaveInterface( + ctx context.Context, + id domain.InterfaceIdentifier, + updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error), +) error { + // Lock the interface to prevent concurrent modifications + mutex := c.getInterfaceMutex(id) + mutex.Lock() + defer mutex.Unlock() + + physicalInterface, err := c.getOrCreateInterface(ctx, id) + if err != nil { + return err + } + + deviceId := physicalInterface.GetExtras().(domain.MikrotikInterfaceExtras).Id + if updateFunc != nil { + physicalInterface, err = updateFunc(physicalInterface) + if err != nil { + return err + } + newExtras := physicalInterface.GetExtras().(domain.MikrotikInterfaceExtras) + newExtras.Id = deviceId // ensure the ID is not changed + physicalInterface.SetExtras(newExtras) + } + + if err := c.updateInterface(ctx, physicalInterface); err != nil { + return err + } + + return nil +} + +func (c *MikrotikController) getOrCreateInterface( + ctx context.Context, + id domain.InterfaceIdentifier, +) (*domain.PhysicalInterface, error) { + wgReply := c.client.Query(ctx, "/interface/wireguard", &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + ".id", "name", "public-key", "private-key", "listen-port", "mtu", "disabled", "running", + }, + Filters: map[string]string{ + "name": string(id), + }, + }) + if wgReply.Status == lowlevel.MikrotikApiStatusOk && len(wgReply.Data) > 0 { + return c.loadInterfaceData(ctx, wgReply.Data[0]) + } + + // create a new interface if it does not exist + createReply := c.client.Create(ctx, "/interface/wireguard", lowlevel.GenericJsonObject{ + "name": string(id), + }) + if wgReply.Status == lowlevel.MikrotikApiStatusOk { + return c.loadInterfaceData(ctx, createReply.Data) + } + + return nil, fmt.Errorf("failed to create interface %s: %v", id, createReply.Error) +} + +func (c *MikrotikController) updateInterface(ctx context.Context, pi *domain.PhysicalInterface) error { + extras := pi.GetExtras().(domain.MikrotikInterfaceExtras) + interfaceId := extras.Id + wgReply := c.client.Update(ctx, "/interface/wireguard/"+interfaceId, lowlevel.GenericJsonObject{ + "name": pi.Identifier, + "comment": extras.Comment, + "mtu": strconv.Itoa(pi.Mtu), + "listen-port": strconv.Itoa(pi.ListenPort), + "private-key": pi.KeyPair.PrivateKey, + "disabled": strconv.FormatBool(!pi.DeviceUp), + }) + if wgReply.Status != lowlevel.MikrotikApiStatusOk { + return fmt.Errorf("failed to update interface %s: %v", pi.Identifier, wgReply.Error) + } + + // update the interface's addresses + currentV4, currentV6, err := c.loadIpAddresses(ctx, string(pi.Identifier)) + if err != nil { + return fmt.Errorf("failed to load current addresses for interface %s: %v", pi.Identifier, err) + } + currentAddresses := c.convertIpAddresses(currentV4, currentV6) + + // get all addresses that are currently not in the interface, only in pi + newAddresses := make([]domain.Cidr, 0, len(pi.Addresses)) + for _, addr := range pi.Addresses { + if slices.Contains(currentAddresses, addr) { + continue + } + newAddresses = append(newAddresses, addr) + } + // get obsolete addresses that are in the interface, but not in pi + obsoleteAddresses := make([]domain.Cidr, 0, len(currentAddresses)) + for _, addr := range currentAddresses { + if slices.Contains(pi.Addresses, addr) { + continue + } + obsoleteAddresses = append(obsoleteAddresses, addr) + } + + // update the IP addresses for the interface + if err := c.updateIpAddresses(ctx, string(pi.Identifier), currentV4, currentV6, + newAddresses, obsoleteAddresses); err != nil { + return fmt.Errorf("failed to update IP addresses for interface %s: %v", pi.Identifier, err) + } + + return nil +} + +func (c *MikrotikController) updateIpAddresses( + ctx context.Context, + deviceName string, + currentV4, currentV6 []lowlevel.GenericJsonObject, + new, obsolete []domain.Cidr, +) error { + // first, delete all obsolete addresses + for _, addr := range obsolete { + // find ID of the address to delete + if addr.IsV4() { + for _, a := range currentV4 { + if a.GetString("address") == addr.String() { + // delete the address + reply := c.client.Delete(ctx, "/ip/address/"+a.GetString(".id")) + if reply.Status != lowlevel.MikrotikApiStatusOk { + return fmt.Errorf("failed to delete obsolete IPv4 address %s: %v", addr, reply.Error) + } + break + } + } + } else { + for _, a := range currentV6 { + if a.GetString("address") == addr.String() { + // delete the address + reply := c.client.Delete(ctx, "/ipv6/address/"+a.GetString(".id")) + if reply.Status != lowlevel.MikrotikApiStatusOk { + return fmt.Errorf("failed to delete obsolete IPv6 address %s: %v", addr, reply.Error) + } + break + } + } + } + } + + // then, add all new addresses + for _, addr := range new { + var createPath string + if addr.IsV4() { + createPath = "/ip/address" + } else { + createPath = "/ipv6/address" + } + + // create the address + reply := c.client.Create(ctx, createPath, lowlevel.GenericJsonObject{ + "address": addr.String(), + "interface": deviceName, + }) + if reply.Status != lowlevel.MikrotikApiStatusOk { + return fmt.Errorf("failed to create new address %s: %v", addr, reply.Error) + } + } + + return nil +} + +func (c *MikrotikController) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error { + // Lock the interface to prevent concurrent modifications + mutex := c.getInterfaceMutex(id) + mutex.Lock() + defer mutex.Unlock() + + // delete the interface's addresses + currentV4, currentV6, err := c.loadIpAddresses(ctx, string(id)) + if err != nil { + return fmt.Errorf("failed to load current addresses for interface %s: %v", id, err) + } + for _, a := range currentV4 { + // delete the address + reply := c.client.Delete(ctx, "/ip/address/"+a.GetString(".id")) + if reply.Status != lowlevel.MikrotikApiStatusOk { + return fmt.Errorf("failed to delete IPv4 address %s: %v", a.GetString("address"), reply.Error) + } + } + for _, a := range currentV6 { + // delete the address + reply := c.client.Delete(ctx, "/ipv6/address/"+a.GetString(".id")) + if reply.Status != lowlevel.MikrotikApiStatusOk { + return fmt.Errorf("failed to delete IPv6 address %s: %v", a.GetString("address"), reply.Error) + } + } + + // delete the WireGuard interface + wgReply := c.client.Query(ctx, "/interface/wireguard", &lowlevel.MikrotikRequestOptions{ + PropList: []string{".id"}, + Filters: map[string]string{ + "name": string(id), + }, + }) + if wgReply.Status != lowlevel.MikrotikApiStatusOk { + return fmt.Errorf("unable to find WireGuard interface %s: %v", id, wgReply.Error) + } + if len(wgReply.Data) == 0 { + return nil // interface does not exist, nothing to delete + } + + interfaceId := wgReply.Data[0].GetString(".id") + deleteReply := c.client.Delete(ctx, "/interface/wireguard/"+interfaceId) + if deleteReply.Status != lowlevel.MikrotikApiStatusOk { + return fmt.Errorf("failed to delete WireGuard interface %s: %v", id, deleteReply.Error) + } + + return nil +} + +func (c *MikrotikController) SavePeer( + ctx context.Context, + deviceId domain.InterfaceIdentifier, + id domain.PeerIdentifier, + updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error), +) error { + // Lock the peer to prevent concurrent modifications + mutex := c.getPeerMutex(id) + mutex.Lock() + defer mutex.Unlock() + + physicalPeer, err := c.getOrCreatePeer(ctx, deviceId, id) + if err != nil { + return err + } + + peerId := physicalPeer.GetExtras().(domain.MikrotikPeerExtras).Id + physicalPeer, err = updateFunc(physicalPeer) + if err != nil { + return err + } + newExtras := physicalPeer.GetExtras().(domain.MikrotikPeerExtras) + newExtras.Id = peerId // ensure the ID is not changed + physicalPeer.SetExtras(newExtras) + + if err := c.updatePeer(ctx, deviceId, physicalPeer); err != nil { + return err + } + + return nil +} + +func (c *MikrotikController) getOrCreatePeer( + ctx context.Context, + deviceId domain.InterfaceIdentifier, + id domain.PeerIdentifier, +) (*domain.PhysicalPeer, error) { + wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{ + PropList: []string{ + ".id", "name", "public-key", "private-key", "preshared-key", "persistent-keepalive", "client-address", + "client-endpoint", "client-keepalive", "allowed-address", "client-dns", "comment", "disabled", "responder", + }, + Filters: map[string]string{ + "public-key": string(id), + "interface": string(deviceId), + }, + }) + if wgReply.Status == lowlevel.MikrotikApiStatusOk && len(wgReply.Data) > 0 { + slog.Debug("found existing Mikrotik peer", "peer", id, "interface", deviceId) + existingPeer, err := c.convertWireGuardPeer(wgReply.Data[0]) + if err != nil { + return nil, err + } + return &existingPeer, nil + } + + // create a new peer if it does not exist + slog.Debug("creating new Mikrotik peer", "peer", id, "interface", deviceId) + createReply := c.client.Create(ctx, "/interface/wireguard/peers", lowlevel.GenericJsonObject{ + "name": fmt.Sprintf("tmp-wg-%s", id[0:8]), + "interface": string(deviceId), + "public-key": string(id), + "allowed-address": "0.0.0.0/0", // Use 0.0.0.0/0 as default, will be updated by updatePeer + }) + if createReply.Status == lowlevel.MikrotikApiStatusOk { + newPeer, err := c.convertWireGuardPeer(createReply.Data) + if err != nil { + return nil, err + } + slog.Debug("successfully created Mikrotik peer", "peer", id, "interface", deviceId) + return &newPeer, nil + } + + return nil, fmt.Errorf("failed to create peer %s for interface %s: %v", id, deviceId, createReply.Error) +} + +func (c *MikrotikController) updatePeer( + ctx context.Context, + deviceId domain.InterfaceIdentifier, + pp *domain.PhysicalPeer, +) error { + extras := pp.GetExtras().(domain.MikrotikPeerExtras) + peerId := extras.Id + + endpoint := pp.Endpoint + endpointPort := "51820" // default port if not set + if s := strings.Split(endpoint, ":"); len(s) == 2 { + endpoint = s[0] + endpointPort = s[1] + } + + allowedAddressStr := domain.CidrsToString(pp.AllowedIPs) + slog.Debug("updating Mikrotik peer", + "peer", pp.Identifier, + "interface", deviceId, + "allowed-address", allowedAddressStr, + "allowed-ips-count", len(pp.AllowedIPs), + "disabled", extras.Disabled) + + wgReply := c.client.Update(ctx, "/interface/wireguard/peers/"+peerId, lowlevel.GenericJsonObject{ + "name": extras.Name, + "comment": extras.Comment, + "preshared-key": pp.PresharedKey, + "public-key": pp.KeyPair.PublicKey, + "private-key": pp.KeyPair.PrivateKey, + "persistent-keepalive": (time.Duration(pp.PersistentKeepalive) * time.Second).String(), + "disabled": strconv.FormatBool(extras.Disabled), + "responder": strconv.FormatBool(extras.IsResponder), + "client-endpoint": extras.ClientEndpoint, + "client-address": extras.ClientAddress, + "client-keepalive": (time.Duration(extras.ClientKeepalive) * time.Second).String(), + "client-dns": extras.ClientDns, + "endpoint-address": endpoint, + "endpoint-port": endpointPort, + "allowed-address": allowedAddressStr, // Add the missing allowed-address field + }) + if wgReply.Status != lowlevel.MikrotikApiStatusOk { + return fmt.Errorf("failed to update peer %s on interface %s: %v", pp.Identifier, deviceId, wgReply.Error) + } + + if extras.Disabled { + slog.Debug("successfully disabled Mikrotik peer", "peer", pp.Identifier, "interface", deviceId) + } else { + slog.Debug("successfully updated Mikrotik peer", "peer", pp.Identifier, "interface", deviceId) + } + + return nil +} + +func (c *MikrotikController) DeletePeer( + ctx context.Context, + deviceId domain.InterfaceIdentifier, + id domain.PeerIdentifier, +) error { + // Lock the peer to prevent concurrent modifications + mutex := c.getPeerMutex(id) + mutex.Lock() + defer mutex.Unlock() + + wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{ + PropList: []string{".id"}, + Filters: map[string]string{ + "public-key": string(id), + "interface": string(deviceId), + }, + }) + if wgReply.Status != lowlevel.MikrotikApiStatusOk { + return fmt.Errorf("unable to find WireGuard peer %s for interface %s: %v", id, deviceId, wgReply.Error) + } + if len(wgReply.Data) == 0 { + return nil // peer does not exist, nothing to delete + } + + peerId := wgReply.Data[0].GetString(".id") + deleteReply := c.client.Delete(ctx, "/interface/wireguard/peers/"+peerId) + if deleteReply.Status != lowlevel.MikrotikApiStatusOk { + return fmt.Errorf("failed to delete WireGuard peer %s for interface %s: %v", id, deviceId, deleteReply.Error) + } + + return nil +} + +// endregion wireguard-related + +// region wg-quick-related + +func (c *MikrotikController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error { + // TODO implement me + panic("implement me") +} + +func (c *MikrotikController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error { + // TODO implement me + panic("implement me") +} + +func (c *MikrotikController) UnsetDNS(id domain.InterfaceIdentifier) error { + // TODO implement me + panic("implement me") +} + +// endregion wg-quick-related + +// region routing-related + +func (c *MikrotikController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error { + // TODO implement me + panic("implement me") +} + +func (c *MikrotikController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error { + // TODO implement me + panic("implement me") +} + +// endregion routing-related + +// region statistics-related + +func (c *MikrotikController) PingAddresses( + ctx context.Context, + addr string, +) (*domain.PingerResult, error) { + wgReply := c.client.ExecList(ctx, "/tool/ping", + // limit to 1 packet with a max running time of 2 seconds + lowlevel.GenericJsonObject{"address": addr, "count": 1, "interval": "00:00:02"}, + ) + + if wgReply.Status != lowlevel.MikrotikApiStatusOk { + return nil, fmt.Errorf("failed to ping %s: %v", addr, wgReply.Error) + } + + var result domain.PingerResult + for _, item := range wgReply.Data { + result.PacketsRecv += item.GetInt("received") + result.PacketsSent += item.GetInt("sent") + + rttStr := item.GetString("avg-rtt") + if rttStr != "" { + rtt, err := time.ParseDuration(rttStr) + if err == nil { + result.Rtts = append(result.Rtts, rtt) + } else { + // use a high value to indicate failure or timeout + result.Rtts = append(result.Rtts, 999999*time.Millisecond) + } + } + } + + return &result, nil +} + +// endregion statistics-related diff --git a/internal/app/api/v0/handlers/endpoint_config.go b/internal/app/api/v0/handlers/endpoint_config.go index 21b342a..9936644 100644 --- a/internal/app/api/v0/handlers/endpoint_config.go +++ b/internal/app/api/v0/handlers/endpoint_config.go @@ -21,17 +21,23 @@ import ( //go:embed frontend_config.js.gotpl var frontendJs embed.FS +type ControllerManager interface { + GetControllerNames() []config.BackendBase +} + type ConfigEndpoint struct { cfg *config.Config authenticator Authenticator + controllerMgr ControllerManager tpl *respond.TemplateRenderer } -func NewConfigEndpoint(cfg *config.Config, authenticator Authenticator) ConfigEndpoint { +func NewConfigEndpoint(cfg *config.Config, authenticator Authenticator, ctrlMgr ControllerManager) ConfigEndpoint { ep := ConfigEndpoint{ cfg: cfg, authenticator: authenticator, + controllerMgr: ctrlMgr, tpl: respond.NewTemplateRenderer(template.Must(template.ParseFS(frontendJs, "frontend_config.js.gotpl"))), } @@ -96,13 +102,36 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sessionUser := domain.GetUserInfo(r.Context()) + controllerFn := func() []model.SettingsBackendNames { + controllers := e.controllerMgr.GetControllerNames() + names := make([]model.SettingsBackendNames, 0, len(controllers)) + + for _, controller := range controllers { + displayName := controller.GetDisplayName() + if displayName == "" { + displayName = controller.Id // fallback to ID if no display name is set + } + if controller.Id == config.LocalBackendName { + displayName = "modals.interface-edit.backend.local" // use a localized string for the local backend + } + names = append(names, model.SettingsBackendNames{ + Id: controller.Id, + Name: displayName, + }) + } + + return names + + } + hasSocialLogin := len(e.cfg.Auth.OAuth) > 0 || len(e.cfg.Auth.OpenIDConnect) > 0 || e.cfg.Auth.WebAuthn.Enabled // For anonymous users, we return the settings object with minimal information if sessionUser.Id == domain.CtxUnknownUserId || sessionUser.Id == "" { respond.JSON(w, http.StatusOK, model.Settings{ - WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, - LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin, + WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, + AvailableBackends: []model.SettingsBackendNames{}, // return an empty list instead of null + LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin, }) } else { respond.JSON(w, http.StatusOK, model.Settings{ @@ -112,6 +141,7 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc { ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly, WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, MinPasswordLength: e.cfg.Auth.MinPasswordLength, + AvailableBackends: controllerFn(), LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin, }) } diff --git a/internal/app/api/v0/model/models.go b/internal/app/api/v0/model/models.go index 5c3ec73..07e2eba 100644 --- a/internal/app/api/v0/model/models.go +++ b/internal/app/api/v0/model/models.go @@ -6,11 +6,17 @@ type Error struct { } type Settings struct { - MailLinkOnly bool `json:"MailLinkOnly"` - PersistentConfigSupported bool `json:"PersistentConfigSupported"` - SelfProvisioning bool `json:"SelfProvisioning"` - ApiAdminOnly bool `json:"ApiAdminOnly"` - WebAuthnEnabled bool `json:"WebAuthnEnabled"` - MinPasswordLength int `json:"MinPasswordLength"` - LoginFormVisible bool `json:"LoginFormVisible"` + MailLinkOnly bool `json:"MailLinkOnly"` + PersistentConfigSupported bool `json:"PersistentConfigSupported"` + SelfProvisioning bool `json:"SelfProvisioning"` + ApiAdminOnly bool `json:"ApiAdminOnly"` + WebAuthnEnabled bool `json:"WebAuthnEnabled"` + MinPasswordLength int `json:"MinPasswordLength"` + AvailableBackends []SettingsBackendNames `json:"AvailableBackends"` + LoginFormVisible bool `json:"LoginFormVisible"` +} + +type SettingsBackendNames struct { + Id string `json:"Id"` + Name string `json:"Name"` } diff --git a/internal/app/api/v0/model/models_interface.go b/internal/app/api/v0/model/models_interface.go index 5684178..1b22d02 100644 --- a/internal/app/api/v0/model/models_interface.go +++ b/internal/app/api/v0/model/models_interface.go @@ -4,6 +4,7 @@ import ( "time" "github.com/h44z/wg-portal/internal" + "github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/domain" ) @@ -11,6 +12,7 @@ type Interface struct { Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0 DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any' + Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ... PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private 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) @@ -57,6 +59,7 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface { Identifier: string(src.Identifier), DisplayName: src.DisplayName, Mode: string(src.Type), + Backend: string(src.Backend), PrivateKey: src.PrivateKey, PublicKey: src.PublicKey, Disabled: src.IsDisabled(), @@ -92,6 +95,10 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface { Filename: src.GetConfigFileName(), } + if iface.Backend == "" { + iface.Backend = config.LocalBackendName // default to local backend + } + if len(peers) > 0 { iface.TotalPeers = len(peers) @@ -146,6 +153,7 @@ func NewDomainInterface(src *Interface) *domain.Interface { SaveConfig: src.SaveConfig, DisplayName: src.DisplayName, Type: domain.InterfaceType(src.Mode), + Backend: domain.InterfaceBackend(src.Backend), DriverType: "", // currently unused Disabled: nil, // set below DisabledReason: src.DisabledReason, diff --git a/internal/app/app.go b/internal/app/app.go index 1eb24cb..e33cfc0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -46,7 +46,7 @@ func Initialize( users: users, } - startupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) + startupContext, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() // Switch to admin user context diff --git a/internal/app/wireguard/controller_manager.go b/internal/app/wireguard/controller_manager.go new file mode 100644 index 0000000..ab1eaa9 --- /dev/null +++ b/internal/app/wireguard/controller_manager.go @@ -0,0 +1,166 @@ +package wireguard + +import ( + "context" + "fmt" + "log/slog" + "maps" + "slices" + + "github.com/h44z/wg-portal/internal/adapters/wgcontroller" + "github.com/h44z/wg-portal/internal/config" + "github.com/h44z/wg-portal/internal/domain" +) + +type InterfaceController interface { + GetId() domain.InterfaceBackend + GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) + GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) + GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) + SaveInterface( + _ context.Context, + id domain.InterfaceIdentifier, + updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error), + ) error + DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error + SavePeer( + _ context.Context, + deviceId domain.InterfaceIdentifier, + id domain.PeerIdentifier, + updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error), + ) error + DeletePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error + PingAddresses( + ctx context.Context, + addr string, + ) (*domain.PingerResult, error) +} + +type backendInstance struct { + Config config.BackendBase // Config is the configuration for the backend instance. + Implementation InterfaceController +} + +type ControllerManager struct { + cfg *config.Config + controllers map[domain.InterfaceBackend]backendInstance +} + +func NewControllerManager(cfg *config.Config) (*ControllerManager, error) { + c := &ControllerManager{ + cfg: cfg, + controllers: make(map[domain.InterfaceBackend]backendInstance), + } + + err := c.init() + if err != nil { + return nil, err + } + + return c, nil +} + +func (c *ControllerManager) init() error { + if err := c.registerLocalController(); err != nil { + return err + } + + if err := c.registerMikrotikControllers(); err != nil { + return err + } + + c.logRegisteredControllers() + + return nil +} + +func (c *ControllerManager) registerLocalController() error { + localController, err := wgcontroller.NewLocalController(c.cfg) + if err != nil { + return fmt.Errorf("failed to create local WireGuard controller: %w", err) + } + + c.controllers[config.LocalBackendName] = backendInstance{ + Config: config.BackendBase{ + Id: config.LocalBackendName, + DisplayName: "Local WireGuard Controller", + }, + Implementation: localController, + } + return nil +} + +func (c *ControllerManager) registerMikrotikControllers() error { + for _, backendConfig := range c.cfg.Backend.Mikrotik { + if backendConfig.Id == config.LocalBackendName { + slog.Warn("skipping registration of Mikrotik controller with reserved ID", "id", config.LocalBackendName) + continue + } + + controller, err := wgcontroller.NewMikrotikController(c.cfg, &backendConfig) + if err != nil { + return fmt.Errorf("failed to create Mikrotik controller for backend %s: %w", backendConfig.Id, err) + } + + c.controllers[domain.InterfaceBackend(backendConfig.Id)] = backendInstance{ + Config: backendConfig.BackendBase, + Implementation: controller, + } + } + return nil +} + +func (c *ControllerManager) logRegisteredControllers() { + for backend, controller := range c.controllers { + slog.Debug("backend controller registered", + "backend", backend, "type", fmt.Sprintf("%T", controller.Implementation)) + } +} + +func (c *ControllerManager) GetControllerByName(backend domain.InterfaceBackend) InterfaceController { + return c.getController(backend, "") +} + +func (c *ControllerManager) GetController(iface domain.Interface) InterfaceController { + return c.getController(iface.Backend, iface.Identifier) +} + +func (c *ControllerManager) getController( + backend domain.InterfaceBackend, + ifaceId domain.InterfaceIdentifier, +) InterfaceController { + if backend == "" { + // If no backend is specified, use the local controller. + // This might be the case for interfaces created in previous WireGuard Portal versions. + backend = config.LocalBackendName + } + + controller, exists := c.controllers[backend] + if !exists { + controller, exists = c.controllers[config.LocalBackendName] // Fallback to local controller + if !exists { + // If the local controller is also not found, panic + panic(fmt.Sprintf("%s interface controller for backend %s not found", ifaceId, backend)) + } + slog.Warn("controller for backend not found, using local controller", + "backend", backend, "interface", ifaceId) + } + return controller.Implementation +} + +func (c *ControllerManager) GetAllControllers() []InterfaceController { + var backendInstances = make([]InterfaceController, 0, len(c.controllers)) + for instance := range maps.Values(c.controllers) { + backendInstances = append(backendInstances, instance.Implementation) + } + return backendInstances +} + +func (c *ControllerManager) GetControllerNames() []config.BackendBase { + var names []config.BackendBase + for _, id := range slices.Sorted(maps.Keys(c.controllers)) { + names = append(names, c.controllers[id].Config) + } + + return names +} diff --git a/internal/app/wireguard/statistics.go b/internal/app/wireguard/statistics.go index 9ab8946..78ca6eb 100644 --- a/internal/app/wireguard/statistics.go +++ b/internal/app/wireguard/statistics.go @@ -6,8 +6,6 @@ import ( "sync" "time" - probing "github.com/prometheus-community/pro-bing" - "github.com/h44z/wg-portal/internal/app" "github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/domain" @@ -30,11 +28,6 @@ type StatisticsDatabaseRepo interface { DeletePeerStatus(ctx context.Context, id domain.PeerIdentifier) error } -type StatisticsInterfaceController interface { - GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) - GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) -} - type StatisticsMetricsServer interface { UpdateInterfaceMetrics(status domain.InterfaceStatus) UpdatePeerMetrics(peer *domain.Peer, status domain.PeerStatus) @@ -47,15 +40,20 @@ type StatisticsEventBus interface { Publish(topic string, args ...any) } +type pingJob struct { + Peer domain.Peer + Backend domain.InterfaceBackend +} + type StatisticsCollector struct { cfg *config.Config bus StatisticsEventBus pingWaitGroup sync.WaitGroup - pingJobs chan domain.Peer + pingJobs chan pingJob db StatisticsDatabaseRepo - wg StatisticsInterfaceController + wg *ControllerManager ms StatisticsMetricsServer peerChangeEvent chan domain.PeerIdentifier @@ -66,7 +64,7 @@ func NewStatisticsCollector( cfg *config.Config, bus StatisticsEventBus, db StatisticsDatabaseRepo, - wg StatisticsInterfaceController, + wg *ControllerManager, ms StatisticsMetricsServer, ) (*StatisticsCollector, error) { c := &StatisticsCollector{ @@ -117,7 +115,7 @@ func (c *StatisticsCollector) collectInterfaceData(ctx context.Context) { } for _, in := range interfaces { - physicalInterface, err := c.wg.GetInterface(ctx, in.Identifier) + physicalInterface, err := c.wg.GetController(in).GetInterface(ctx, in.Identifier) if err != nil { slog.Warn("failed to load physical interface for data collection", "interface", in.Identifier, "error", err) @@ -169,7 +167,7 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) { } for _, in := range interfaces { - peers, err := c.wg.GetPeers(ctx, in.Identifier) + peers, err := c.wg.GetController(in).GetPeers(ctx, in.Identifier) if err != nil { slog.Warn("failed to fetch peers for data collection", "interface", in.Identifier, "error", err) continue @@ -271,7 +269,7 @@ func (c *StatisticsCollector) startPingWorkers(ctx context.Context) { c.pingWaitGroup = sync.WaitGroup{} c.pingWaitGroup.Add(c.cfg.Statistics.PingCheckWorkers) - c.pingJobs = make(chan domain.Peer, c.cfg.Statistics.PingCheckWorkers) + c.pingJobs = make(chan pingJob, c.cfg.Statistics.PingCheckWorkers) // start workers for i := 0; i < c.cfg.Statistics.PingCheckWorkers; i++ { @@ -314,7 +312,10 @@ func (c *StatisticsCollector) enqueuePingChecks(ctx context.Context) { continue } for _, peer := range peers { - c.pingJobs <- peer + c.pingJobs <- pingJob{ + Peer: peer, + Backend: in.Backend, + } } } } @@ -323,11 +324,14 @@ func (c *StatisticsCollector) enqueuePingChecks(ctx context.Context) { func (c *StatisticsCollector) pingWorker(ctx context.Context) { defer c.pingWaitGroup.Done() - for peer := range c.pingJobs { + for job := range c.pingJobs { + peer := job.Peer + backend := job.Backend + var connectionStateChanged bool var newPeerStatus domain.PeerStatus - peerPingable := c.isPeerPingable(ctx, peer) + peerPingable := c.isPeerPingable(ctx, backend, peer) slog.Debug("peer ping check completed", "peer", peer.Identifier, "pingable", peerPingable) now := time.Now() @@ -368,7 +372,11 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) { } } -func (c *StatisticsCollector) isPeerPingable(ctx context.Context, peer domain.Peer) bool { +func (c *StatisticsCollector) isPeerPingable( + ctx context.Context, + backend domain.InterfaceBackend, + peer domain.Peer, +) bool { if !c.cfg.Statistics.UsePingChecks { return false } @@ -378,23 +386,13 @@ func (c *StatisticsCollector) isPeerPingable(ctx context.Context, peer domain.Pe return false } - pinger, err := probing.NewPinger(checkAddr) + stats, err := c.wg.GetControllerByName(backend).PingAddresses(ctx, checkAddr) if err != nil { - slog.Debug("failed to instantiate pinger", "peer", peer.Identifier, "address", checkAddr, "error", err) + slog.Debug("failed to ping peer", "peer", peer.Identifier, "error", err) return false } - checkCount := 1 - pinger.SetPrivileged(!c.cfg.Statistics.PingUnprivileged) - pinger.Count = checkCount - pinger.Timeout = 2 * time.Second - err = pinger.RunWithContext(ctx) // Blocks until finished. - if err != nil { - slog.Debug("pinger for peer exited unexpectedly", "peer", peer.Identifier, "address", checkAddr, "error", err) - return false - } - stats := pinger.Statistics() - return stats.PacketsRecv == checkCount + return stats.IsPingable() } func (c *StatisticsCollector) updateInterfaceMetrics(status domain.InterfaceStatus) { diff --git a/internal/app/wireguard/wireguard.go b/internal/app/wireguard/wireguard.go index eb76bc9..b28f70e 100644 --- a/internal/app/wireguard/wireguard.go +++ b/internal/app/wireguard/wireguard.go @@ -37,25 +37,6 @@ type InterfaceAndPeerDatabaseRepo interface { GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr) (map[domain.Cidr][]domain.Cidr, error) } -type InterfaceController interface { - GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) - GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) - GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) - SaveInterface( - _ context.Context, - id domain.InterfaceIdentifier, - updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error), - ) error - DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error - SavePeer( - _ context.Context, - deviceId domain.InterfaceIdentifier, - id domain.PeerIdentifier, - updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error), - ) error - DeletePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error -} - type WgQuickController interface { ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error @@ -75,7 +56,7 @@ type Manager struct { cfg *config.Config bus EventBus db InterfaceAndPeerDatabaseRepo - wg InterfaceController + wg *ControllerManager quick WgQuickController userLockMap *sync.Map @@ -84,7 +65,7 @@ type Manager struct { func NewWireGuardManager( cfg *config.Config, bus EventBus, - wg InterfaceController, + wg *ControllerManager, quick WgQuickController, db InterfaceAndPeerDatabaseRepo, ) (*Manager, error) { diff --git a/internal/app/wireguard/wireguard_interfaces.go b/internal/app/wireguard/wireguard_interfaces.go index 17a28bc..22b6658 100644 --- a/internal/app/wireguard/wireguard_interfaces.go +++ b/internal/app/wireguard/wireguard_interfaces.go @@ -11,6 +11,7 @@ import ( "github.com/h44z/wg-portal/internal/app" "github.com/h44z/wg-portal/internal/app/audit" + "github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/domain" ) @@ -21,12 +22,17 @@ func (m Manager) GetImportableInterfaces(ctx context.Context) ([]domain.Physical return nil, err } - physicalInterfaces, err := m.wg.GetInterfaces(ctx) - if err != nil { - return nil, err + var allPhysicalInterfaces []domain.PhysicalInterface + for _, wgBackend := range m.wg.GetAllControllers() { + physicalInterfaces, err := wgBackend.GetInterfaces(ctx) + if err != nil { + return nil, err + } + + allPhysicalInterfaces = append(allPhysicalInterfaces, physicalInterfaces...) } - return physicalInterfaces, nil + return allPhysicalInterfaces, nil } // GetInterfaceAndPeers returns the interface and all peers for the given interface identifier. @@ -109,47 +115,49 @@ func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.Inter return 0, err } - physicalInterfaces, err := m.wg.GetInterfaces(ctx) - if err != nil { - return 0, err - } - - // if no filter is given, exclude already existing interfaces - var excludedInterfaces []domain.InterfaceIdentifier - if len(filter) == 0 { - existingInterfaces, err := m.db.GetAllInterfaces(ctx) - if err != nil { - return 0, err - } - for _, existingInterface := range existingInterfaces { - excludedInterfaces = append(excludedInterfaces, existingInterface.Identifier) - } - } - imported := 0 - for _, physicalInterface := range physicalInterfaces { - if slices.Contains(excludedInterfaces, physicalInterface.Identifier) { - continue - } - - if len(filter) != 0 && !slices.Contains(filter, physicalInterface.Identifier) { - continue - } - - slog.Info("importing new interface", "interface", physicalInterface.Identifier) - - physicalPeers, err := m.wg.GetPeers(ctx, physicalInterface.Identifier) + for _, wgBackend := range m.wg.GetAllControllers() { + physicalInterfaces, err := wgBackend.GetInterfaces(ctx) if err != nil { return 0, err } - err = m.importInterface(ctx, &physicalInterface, physicalPeers) - if err != nil { - return 0, fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err) + // if no filter is given, exclude already existing interfaces + var excludedInterfaces []domain.InterfaceIdentifier + if len(filter) == 0 { + existingInterfaces, err := m.db.GetAllInterfaces(ctx) + if err != nil { + return 0, err + } + for _, existingInterface := range existingInterfaces { + excludedInterfaces = append(excludedInterfaces, existingInterface.Identifier) + } } - slog.Info("imported new interface", "interface", physicalInterface.Identifier, "peers", len(physicalPeers)) - imported++ + for _, physicalInterface := range physicalInterfaces { + if slices.Contains(excludedInterfaces, physicalInterface.Identifier) { + continue + } + + if len(filter) != 0 && !slices.Contains(filter, physicalInterface.Identifier) { + continue + } + + slog.Info("importing new interface", "interface", physicalInterface.Identifier) + + physicalPeers, err := wgBackend.GetPeers(ctx, physicalInterface.Identifier) + if err != nil { + return 0, err + } + + err = m.importInterface(ctx, wgBackend, &physicalInterface, physicalPeers) + if err != nil { + return 0, fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err) + } + + slog.Info("imported new interface", "interface", physicalInterface.Identifier, "peers", len(physicalPeers)) + imported++ + } } return imported, nil @@ -213,7 +221,7 @@ func (m Manager) RestoreInterfaceState( return fmt.Errorf("failed to load peers for %s: %w", iface.Identifier, err) } - _, err = m.wg.GetInterface(ctx, iface.Identifier) + _, err = m.wg.GetController(iface).GetInterface(ctx, iface.Identifier) if err != nil && !iface.IsDisabled() { slog.Debug("creating missing interface", "interface", iface.Identifier) @@ -260,18 +268,14 @@ func (m Manager) RestoreInterfaceState( // restore peers for _, peer := range peers { switch { - case iface.IsDisabled(): // if interface is disabled, delete all peers - if err := m.wg.DeletePeer(ctx, iface.Identifier, peer.Identifier); err != nil { + case iface.IsDisabled() && iface.Backend == config.LocalBackendName: // if interface is disabled, delete all peers + if err := m.wg.GetController(iface).DeletePeer(ctx, iface.Identifier, + peer.Identifier); err != nil { return fmt.Errorf("failed to remove peer %s for disabled interface %s: %w", peer.Identifier, iface.Identifier, err) } - case peer.IsDisabled(): // if peer is disabled, delete it - if err := m.wg.DeletePeer(ctx, iface.Identifier, peer.Identifier); err != nil { - return fmt.Errorf("failed to remove disbaled peer %s from interface %s: %w", - peer.Identifier, iface.Identifier, err) - } default: // update peer - err := m.wg.SavePeer(ctx, iface.Identifier, peer.Identifier, + err := m.wg.GetController(iface).SavePeer(ctx, iface.Identifier, peer.Identifier, func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) { domain.MergeToPhysicalPeer(pp, &peer) return pp, nil @@ -284,7 +288,7 @@ func (m Manager) RestoreInterfaceState( } // remove non-wgportal peers - physicalPeers, _ := m.wg.GetPeers(ctx, iface.Identifier) + physicalPeers, _ := m.wg.GetController(iface).GetPeers(ctx, iface.Identifier) for _, physicalPeer := range physicalPeers { isWgPortalPeer := false for _, peer := range peers { @@ -294,7 +298,8 @@ func (m Manager) RestoreInterfaceState( } } if !isWgPortalPeer { - err := m.wg.DeletePeer(ctx, iface.Identifier, domain.PeerIdentifier(physicalPeer.PublicKey)) + err := m.wg.GetController(iface).DeletePeer(ctx, iface.Identifier, + domain.PeerIdentifier(physicalPeer.PublicKey)) if err != nil { return fmt.Errorf("failed to remove non-wgportal peer %s from interface %s: %w", physicalPeer.PublicKey, iface.Identifier, err) @@ -459,7 +464,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif existingInterface.Disabled = &now // simulate a disabled interface existingInterface.DisabledReason = domain.DisabledReasonDeleted - physicalInterface, _ := m.wg.GetInterface(ctx, id) + physicalInterface, _ := m.wg.GetController(*existingInterface).GetInterface(ctx, id) if err := m.handleInterfacePreSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil { return fmt.Errorf("pre-delete hooks failed: %w", err) @@ -473,7 +478,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif return fmt.Errorf("peer deletion failure: %w", err) } - if err := m.wg.DeleteInterface(ctx, id); err != nil { + if err := m.wg.GetController(*existingInterface).DeleteInterface(ctx, id); err != nil { return fmt.Errorf("wireguard deletion failure: %w", err) } @@ -522,7 +527,7 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) ( err := m.db.SaveInterface(ctx, iface.Identifier, func(i *domain.Interface) (*domain.Interface, error) { iface.CopyCalculatedAttributes(i) - err := m.wg.SaveInterface(ctx, iface.Identifier, + err := m.wg.GetController(*iface).SaveInterface(ctx, iface.Identifier, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) { domain.MergeToPhysicalInterface(pi, iface) return pi, nil @@ -538,7 +543,7 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) ( } if iface.IsDisabled() { - physicalInterface, _ := m.wg.GetInterface(ctx, iface.Identifier) + physicalInterface, _ := m.wg.GetController(*iface).GetInterface(ctx, iface.Identifier) fwMark := iface.FirewallMark if physicalInterface != nil && fwMark == 0 { fwMark = physicalInterface.FirewallMark @@ -556,13 +561,13 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) ( } // If the interface has just been enabled, restore its peers on the physical controller - if !oldEnabled && newEnabled { + if !oldEnabled && newEnabled && iface.Backend == config.LocalBackendName { peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier) if err != nil { return nil, fmt.Errorf("failed to load peers for interface %s: %w", iface.Identifier, err) } for _, peer := range peers { - saveErr := m.wg.SavePeer(ctx, iface.Identifier, peer.Identifier, + saveErr := m.wg.GetController(*iface).SavePeer(ctx, iface.Identifier, peer.Identifier, func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) { domain.MergeToPhysicalPeer(pp, &peer) return pp, nil @@ -766,7 +771,12 @@ func (m Manager) getFreshListenPort(ctx context.Context) (port int, err error) { return } -func (m Manager) importInterface(ctx context.Context, in *domain.PhysicalInterface, peers []domain.PhysicalPeer) error { +func (m Manager) importInterface( + ctx context.Context, + backend InterfaceController, + in *domain.PhysicalInterface, + peers []domain.PhysicalPeer, +) error { now := time.Now() iface := domain.ConvertPhysicalInterface(in) iface.BaseModel = domain.BaseModel{ @@ -775,8 +785,20 @@ func (m Manager) importInterface(ctx context.Context, in *domain.PhysicalInterfa CreatedAt: now, UpdatedAt: now, } + iface.Backend = backend.GetId() iface.PeerDefAllowedIPsStr = iface.AddressStr() + // try to predict the interface type based on the number of peers + switch len(peers) { + case 0: + iface.Type = domain.InterfaceTypeAny // no peers means this is an unknown interface + case 1: + iface.Type = domain.InterfaceTypeClient // one peer means this is a client interface + default: // multiple peers means this is a server interface + + iface.Type = domain.InterfaceTypeServer + } + existingInterface, err := m.db.GetInterface(ctx, iface.Identifier) if err != nil && !errors.Is(err, domain.ErrNotFound) { return err @@ -827,16 +849,20 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain peer.Interface.PreDown = domain.NewConfigOption(in.PeerDefPreDown, true) peer.Interface.PostDown = domain.NewConfigOption(in.PeerDefPostDown, true) + var displayName string switch in.Type { case domain.InterfaceTypeAny: peer.Interface.Type = domain.InterfaceTypeAny - peer.DisplayName = "Autodetected Peer (" + peer.Interface.PublicKey[0:8] + ")" + displayName = "Autodetected Peer (" + peer.Interface.PublicKey[0:8] + ")" case domain.InterfaceTypeClient: peer.Interface.Type = domain.InterfaceTypeServer - peer.DisplayName = "Autodetected Endpoint (" + peer.Interface.PublicKey[0:8] + ")" + displayName = "Autodetected Endpoint (" + peer.Interface.PublicKey[0:8] + ")" case domain.InterfaceTypeServer: peer.Interface.Type = domain.InterfaceTypeClient - peer.DisplayName = "Autodetected Client (" + peer.Interface.PublicKey[0:8] + ")" + displayName = "Autodetected Client (" + peer.Interface.PublicKey[0:8] + ")" + } + if peer.DisplayName == "" { + peer.DisplayName = displayName // use auto-generated display name if not set } err := m.db.SavePeer(ctx, peer.Identifier, func(_ *domain.Peer) (*domain.Peer, error) { @@ -850,12 +876,12 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain } func (m Manager) deleteInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) error { - allPeers, err := m.db.GetInterfacePeers(ctx, id) + iface, allPeers, err := m.db.GetInterfaceAndPeers(ctx, id) if err != nil { return err } for _, peer := range allPeers { - err = m.wg.DeletePeer(ctx, id, peer.Identifier) + err = m.wg.GetController(*iface).DeletePeer(ctx, id, peer.Identifier) if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("wireguard peer deletion failure for %s: %w", peer.Identifier, err) } diff --git a/internal/app/wireguard/wireguard_peers.go b/internal/app/wireguard/wireguard_peers.go index 500d5bb..99ccdcf 100644 --- a/internal/app/wireguard/wireguard_peers.go +++ b/internal/app/wireguard/wireguard_peers.go @@ -371,7 +371,12 @@ func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error return fmt.Errorf("delete not allowed: %w", err) } - err = m.wg.DeletePeer(ctx, peer.InterfaceIdentifier, id) + iface, err := m.db.GetInterface(ctx, peer.InterfaceIdentifier) + if err != nil { + return fmt.Errorf("unable to find interface %s: %w", peer.InterfaceIdentifier, err) + } + + err = m.wg.GetController(*iface).DeletePeer(ctx, peer.InterfaceIdentifier, id) if err != nil { return fmt.Errorf("wireguard failed to delete peer %s: %w", id, err) } @@ -433,35 +438,28 @@ func (m Manager) GetUserPeerStats(ctx context.Context, id domain.UserIdentifier) func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error { interfaces := make(map[domain.InterfaceIdentifier]struct{}) - for i := range peers { - peer := peers[i] - var err error - if peer.IsDisabled() || peer.IsExpired() { - err = m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) { - peer.CopyCalculatedAttributes(p) - - if err := m.wg.DeletePeer(ctx, peer.InterfaceIdentifier, peer.Identifier); err != nil { - return nil, fmt.Errorf("failed to delete wireguard peer %s: %w", peer.Identifier, err) - } - - return peer, nil - }) - } else { - err = m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) { - peer.CopyCalculatedAttributes(p) - - err := m.wg.SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier, - func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) { - domain.MergeToPhysicalPeer(pp, peer) - return pp, nil - }) - if err != nil { - return nil, fmt.Errorf("failed to save wireguard peer %s: %w", peer.Identifier, err) - } - - return peer, nil - }) + for _, peer := range peers { + iface, err := m.db.GetInterface(ctx, peer.InterfaceIdentifier) + if err != nil { + return fmt.Errorf("unable to find interface %s: %w", peer.InterfaceIdentifier, err) } + + // Always save the peer to the backend, regardless of disabled/expired state + // The backend will handle the disabled state appropriately + err = m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) { + peer.CopyCalculatedAttributes(p) + + err := m.wg.GetController(*iface).SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier, + func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) { + domain.MergeToPhysicalPeer(pp, peer) + return pp, nil + }) + if err != nil { + return nil, fmt.Errorf("failed to save wireguard peer %s: %w", peer.Identifier, err) + } + + return peer, nil + }) if err != nil { return fmt.Errorf("save failure for peer %s: %w", peer.Identifier, err) } diff --git a/internal/config/backend.go b/internal/config/backend.go new file mode 100644 index 0000000..f81adad --- /dev/null +++ b/internal/config/backend.go @@ -0,0 +1,94 @@ +package config + +import ( + "fmt" + "time" +) + +const LocalBackendName = "local" + +type Backend struct { + Default string `yaml:"default"` // The default backend to use (defaults to the internal backend) + + Mikrotik []BackendMikrotik `yaml:"mikrotik"` +} + +// Validate checks the backend configuration for errors. +func (b *Backend) Validate() error { + if b.Default == "" { + b.Default = LocalBackendName + } + + uniqueMap := make(map[string]struct{}) + for _, backend := range b.Mikrotik { + if backend.Id == LocalBackendName { + return fmt.Errorf("backend ID %q is a reserved keyword", LocalBackendName) + } + if _, exists := uniqueMap[backend.Id]; exists { + return fmt.Errorf("backend ID %q is not unique", backend.Id) + } + uniqueMap[backend.Id] = struct{}{} + } + + if b.Default != LocalBackendName { + if _, ok := uniqueMap[b.Default]; !ok { + return fmt.Errorf("default backend %q is not defined in the configuration", b.Default) + } + } + + return nil +} + +type BackendBase struct { + Id string `yaml:"id"` // A unique id for the backend + DisplayName string `yaml:"display_name"` // A display name for the backend +} + +// GetDisplayName returns the display name of the backend. +// If no display name is set, it falls back to the ID. +func (b BackendBase) GetDisplayName() string { + if b.DisplayName == "" { + return b.Id // Fallback to ID if no display name is set + } + return b.DisplayName +} + +type BackendMikrotik struct { + BackendBase `yaml:",inline"` // Embed the base fields + + ApiUrl string `yaml:"api_url"` // The base URL of the Mikrotik API (e.g., "https://10.10.10.10:8729/rest") + ApiUser string `yaml:"api_user"` + ApiPassword string `yaml:"api_password"` + ApiVerifyTls bool `yaml:"api_verify_tls"` // Whether to verify the TLS certificate of the Mikrotik API + ApiTimeout time.Duration `yaml:"api_timeout"` // Timeout for API requests (default: 30 seconds) + + // Concurrency controls the maximum number of concurrent API requests that this backend will issue + // when enumerating interfaces and their details. If 0 or negative, a default of 5 is used. + Concurrency int `yaml:"concurrency"` + + Debug bool `yaml:"debug"` // Enable debug logging for the Mikrotik backend +} + +// GetConcurrency returns the configured concurrency for this backend or a sane default (5) +// when the configured value is zero or negative. +func (b *BackendMikrotik) GetConcurrency() int { + if b == nil { + return 5 + } + if b.Concurrency <= 0 { + return 5 + } + return b.Concurrency +} + +// GetApiTimeout returns the configured API timeout or a sane default (30 seconds) +// when the configured value is zero or negative. +func (b *BackendMikrotik) GetApiTimeout() time.Duration { + if b == nil { + return 30 * time.Second + } + if b.ApiTimeout <= 0 { + return 30 * time.Second + } + return b.ApiTimeout +} diff --git a/internal/config/config.go b/internal/config/config.go index e64a703..0574133 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,8 @@ type Config struct { LimitAdditionalUserPeers int `yaml:"limit_additional_user_peers"` } `yaml:"advanced"` + Backend Backend `yaml:"backend"` + Statistics struct { UsePingChecks bool `yaml:"use_ping_checks"` PingCheckWorkers int `yaml:"ping_check_workers"` @@ -99,6 +101,12 @@ func (c *Config) LogStartupValues() { "minPasswordLength", c.Auth.MinPasswordLength, "hideLoginForm", c.Auth.HideLoginForm, ) + + slog.Debug("Config Backend", + "defaultBackend", c.Backend.Default, + "extraBackends", len(c.Backend.Mikrotik), + ) + } // defaultConfig returns the default configuration @@ -122,6 +130,10 @@ func defaultConfig() *Config { DSN: "data/sqlite.db", } + cfg.Backend = Backend{ + Default: LocalBackendName, // local backend is the default (using wgcrtl) + } + cfg.Web = WebConfig{ RequestLogging: false, ExternalUrl: "http://localhost:8888", @@ -201,6 +213,10 @@ func GetConfig() (*Config, error) { } cfg.Web.Sanitize() + err := cfg.Backend.Validate() + if err != nil { + return nil, err + } return cfg, nil } diff --git a/internal/domain/controller.go b/internal/domain/controller.go new file mode 100644 index 0000000..eaefe32 --- /dev/null +++ b/internal/domain/controller.go @@ -0,0 +1,32 @@ +package domain + +// ControllerType defines the type of controller used to manage interfaces. + +const ( + ControllerTypeMikrotik = "mikrotik" + ControllerTypeLocal = "wgctrl" +) + +// Controller extras can be used to store additional information available for specific controllers only. + +type MikrotikInterfaceExtras struct { + Id string // internal mikrotik ID + Comment string + Disabled bool +} + +type MikrotikPeerExtras struct { + Id string // internal mikrotik ID + Name string + Comment string + IsResponder bool + Disabled bool + ClientEndpoint string + ClientAddress string + ClientDns string + ClientKeepalive int +} + +type LocalPeerExtras struct { + Disabled bool +} diff --git a/internal/domain/interface.go b/internal/domain/interface.go index 977f7d3..32fc1c0 100644 --- a/internal/domain/interface.go +++ b/internal/domain/interface.go @@ -10,6 +10,8 @@ import ( "strings" "time" + "golang.org/x/sys/unix" + "github.com/h44z/wg-portal/internal" ) @@ -23,6 +25,7 @@ var allowedFileNameRegex = regexp.MustCompile("[^a-zA-Z0-9-_]+") type InterfaceIdentifier string type InterfaceType string +type InterfaceBackend string type Interface struct { BaseModel @@ -49,11 +52,12 @@ type Interface struct { SaveConfig bool // automatically persist config changes to the wgX.conf file // WG Portal specific - DisplayName string // a nice display name/ description for the interface - Type InterfaceType // the interface type, either InterfaceTypeServer or InterfaceTypeClient - DriverType string // the interface driver type (linux, software, ...) - 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 + DisplayName string // a nice display name/ description for the interface + Type InterfaceType // the interface type, either InterfaceTypeServer or InterfaceTypeClient + Backend InterfaceBackend // the backend that is used to manage the interface (wgctrl, mikrotik, ...) + DriverType string // the interface driver type (linux, software, ...) + 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 // Default settings for the peer, used for new peers, those settings will be published to ConfigOption options of // the peer config @@ -204,9 +208,31 @@ type PhysicalInterface struct { BytesUpload uint64 BytesDownload uint64 + + backendExtras any // additional backend-specific extras, e.g., domain.MikrotikInterfaceExtras +} + +func (p *PhysicalInterface) GetExtras() any { + return p.backendExtras +} + +func (p *PhysicalInterface) SetExtras(extras any) { + switch extras.(type) { + case MikrotikInterfaceExtras: // OK + default: // we only support MikrotikInterfaceExtras for now + panic(fmt.Sprintf("unsupported interface backend extras type %T", extras)) + } + + p.backendExtras = extras } func ConvertPhysicalInterface(pi *PhysicalInterface) *Interface { + networks := make([]Cidr, 0, len(pi.Addresses)) + for _, addr := range pi.Addresses { + networks = append(networks, addr.NetworkAddr()) + } + + // create a new basic interface with the data from the physical interface iface := &Interface{ Identifier: pi.Identifier, KeyPair: pi.KeyPair, @@ -226,11 +252,11 @@ func ConvertPhysicalInterface(pi *PhysicalInterface) *Interface { Type: InterfaceTypeAny, DriverType: pi.DeviceType, Disabled: nil, - PeerDefNetworkStr: "", + PeerDefNetworkStr: CidrsToString(networks), PeerDefDnsStr: "", PeerDefDnsSearchStr: "", PeerDefEndpoint: "", - PeerDefAllowedIPsStr: "", + PeerDefAllowedIPsStr: CidrsToString(networks), PeerDefMtu: pi.Mtu, PeerDefPersistentKeepalive: 0, PeerDefFirewallMark: 0, @@ -241,6 +267,23 @@ func ConvertPhysicalInterface(pi *PhysicalInterface) *Interface { PeerDefPostDown: "", } + if pi.GetExtras() == nil { + return iface + } + + // enrich the data with controller-specific extras + now := time.Now() + switch pi.ImportSource { + case ControllerTypeMikrotik: + extras := pi.GetExtras().(MikrotikInterfaceExtras) + iface.DisplayName = extras.Comment + if extras.Disabled { + iface.Disabled = &now + } else { + iface.Disabled = nil + } + } + return iface } @@ -253,6 +296,15 @@ func MergeToPhysicalInterface(pi *PhysicalInterface, i *Interface) { pi.FirewallMark = i.FirewallMark pi.DeviceUp = !i.IsDisabled() pi.Addresses = i.Addresses + + switch pi.ImportSource { + case ControllerTypeMikrotik: + extras := MikrotikInterfaceExtras{ + Comment: i.DisplayName, + Disabled: i.IsDisabled(), + } + pi.SetExtras(extras) + } } type RoutingTableInfo struct { @@ -279,3 +331,30 @@ func (r RoutingTableInfo) GetRoutingTable() int { return r.Table } + +type IpFamily int + +const ( + IpFamilyIPv4 IpFamily = unix.AF_INET + IpFamilyIPv6 IpFamily = unix.AF_INET6 +) + +func (f IpFamily) String() string { + switch f { + case IpFamilyIPv4: + return "IPv4" + case IpFamilyIPv6: + return "IPv6" + default: + return "unknown" + } +} + +// RouteRule represents a routing table rule. +type RouteRule struct { + InterfaceId InterfaceIdentifier + IpFamily IpFamily + FwMark uint32 + Table int + HasDefault bool +} diff --git a/internal/domain/peer.go b/internal/domain/peer.go index 93404eb..519d551 100644 --- a/internal/domain/peer.go +++ b/internal/domain/peer.go @@ -129,7 +129,7 @@ func (p *Peer) GenerateDisplayName(prefix string) { p.DisplayName = fmt.Sprintf("%sPeer %s", prefix, internal.TruncateString(string(p.Identifier), 8)) } -// OverwriteUserEditableFields overwrites the user editable fields of the peer with the values from the userPeer +// OverwriteUserEditableFields overwrites the user-editable fields of the peer with the values from the userPeer func (p *Peer) OverwriteUserEditableFields(userPeer *Peer, cfg *config.Config) { p.DisplayName = userPeer.DisplayName if cfg.Core.EditableKeys { @@ -182,9 +182,12 @@ type PhysicalPeer struct { BytesUpload uint64 // upload bytes are the number of bytes that the remote peer has sent to the server BytesDownload uint64 // upload bytes are the number of bytes that the remote peer has received from the server + + ImportSource string // import source (wgctrl, file, ...) + backendExtras any // additional backend-specific extras, e.g., domain.MikrotikPeerExtras } -func (p PhysicalPeer) GetPresharedKey() *wgtypes.Key { +func (p *PhysicalPeer) GetPresharedKey() *wgtypes.Key { if p.PresharedKey == "" { return nil } @@ -196,7 +199,7 @@ func (p PhysicalPeer) GetPresharedKey() *wgtypes.Key { return &key } -func (p PhysicalPeer) GetEndpointAddress() *net.UDPAddr { +func (p *PhysicalPeer) GetEndpointAddress() *net.UDPAddr { if p.Endpoint == "" { return nil } @@ -208,7 +211,7 @@ func (p PhysicalPeer) GetEndpointAddress() *net.UDPAddr { return addr } -func (p PhysicalPeer) GetPersistentKeepaliveTime() *time.Duration { +func (p *PhysicalPeer) GetPersistentKeepaliveTime() *time.Duration { if p.PersistentKeepalive == 0 { return nil } @@ -217,7 +220,7 @@ func (p PhysicalPeer) GetPersistentKeepaliveTime() *time.Duration { return &keepAliveDuration } -func (p PhysicalPeer) GetAllowedIPs() []net.IPNet { +func (p *PhysicalPeer) GetAllowedIPs() []net.IPNet { allowedIPs := make([]net.IPNet, len(p.AllowedIPs)) for i, ip := range p.AllowedIPs { allowedIPs[i] = *ip.IpNet() @@ -226,6 +229,21 @@ func (p PhysicalPeer) GetAllowedIPs() []net.IPNet { return allowedIPs } +func (p *PhysicalPeer) GetExtras() any { + return p.backendExtras +} + +func (p *PhysicalPeer) SetExtras(extras any) { + switch extras.(type) { + case MikrotikPeerExtras: // OK + case LocalPeerExtras: // OK + default: // we only support MikrotikPeerExtras and LocalPeerExtras for now + panic(fmt.Sprintf("unsupported peer backend extras type %T", extras)) + } + + p.backendExtras = extras +} + func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer { peer := &Peer{ Endpoint: NewConfigOption(pp.Endpoint, true), @@ -244,6 +262,44 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer { }, } + if pp.GetExtras() == nil { + return peer + } + + // enrich the data with controller-specific extras + now := time.Now() + switch pp.ImportSource { + case ControllerTypeMikrotik: + extras := pp.GetExtras().(MikrotikPeerExtras) + peer.Notes = extras.Comment + peer.DisplayName = extras.Name + if extras.ClientEndpoint != "" { // if the client endpoint is set, we assume that this is a client peer + peer.Endpoint = NewConfigOption(extras.ClientEndpoint, true) + peer.Interface.Type = InterfaceTypeClient + peer.Interface.Addresses, _ = CidrsFromString(extras.ClientAddress) + peer.Interface.DnsStr = NewConfigOption(extras.ClientDns, true) + peer.PersistentKeepalive = NewConfigOption(extras.ClientKeepalive, true) + } else { + peer.Interface.Type = InterfaceTypeServer + } + if extras.Disabled { + peer.Disabled = &now + peer.DisabledReason = "Disabled by Mikrotik controller" + } else { + peer.Disabled = nil + peer.DisabledReason = "" + } + case ControllerTypeLocal: + extras := pp.GetExtras().(LocalPeerExtras) + if extras.Disabled { + peer.Disabled = &now + peer.DisabledReason = "Disabled by Local controller" + } else { + peer.Disabled = nil + peer.DisabledReason = "" + } + } + return peer } @@ -265,6 +321,27 @@ func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) { pp.PresharedKey = p.PresharedKey pp.PublicKey = p.Interface.PublicKey pp.PersistentKeepalive = p.PersistentKeepalive.GetValue() + + switch pp.ImportSource { + case ControllerTypeMikrotik: + extras := MikrotikPeerExtras{ + Id: "", + Name: p.DisplayName, + Comment: p.Notes, + IsResponder: false, + Disabled: p.IsDisabled(), + ClientEndpoint: p.Endpoint.GetValue(), + ClientAddress: CidrsToString(p.Interface.Addresses), + ClientDns: p.Interface.DnsStr.GetValue(), + ClientKeepalive: p.PersistentKeepalive.GetValue(), + } + pp.SetExtras(extras) + case ControllerTypeLocal: + extras := LocalPeerExtras{ + Disabled: p.IsDisabled(), + } + pp.SetExtras(extras) + } } type PeerCreationRequest struct { diff --git a/internal/domain/statistics.go b/internal/domain/statistics.go index b4a3dca..cbc987d 100644 --- a/internal/domain/statistics.go +++ b/internal/domain/statistics.go @@ -1,6 +1,8 @@ package domain -import "time" +import ( + "time" +) type PeerStatus struct { PeerId PeerIdentifier `gorm:"primaryKey;column:identifier" json:"PeerId"` @@ -37,3 +39,25 @@ type InterfaceStatus struct { BytesReceived uint64 `gorm:"column:received"` BytesTransmitted uint64 `gorm:"column:transmitted"` } + +type PingerResult struct { + PacketsRecv int + PacketsSent int + Rtts []time.Duration +} + +func (r PingerResult) IsPingable() bool { + return r.PacketsRecv > 0 && r.PacketsSent > 0 && len(r.Rtts) > 0 +} + +func (r PingerResult) AverageRtt() time.Duration { + if len(r.Rtts) == 0 { + return 0 + } + + var total time.Duration + for _, rtt := range r.Rtts { + total += rtt + } + return total / time.Duration(len(r.Rtts)) +} diff --git a/internal/logger.go b/internal/logger.go index 994c236..9bca8c8 100644 --- a/internal/logger.go +++ b/internal/logger.go @@ -12,8 +12,8 @@ import ( "sync" ) -// SetupLogging initializes the global logger with the given level and format -func SetupLogging(level string, pretty, json bool) { +// GetLoggingHandler initializes a slog.Handler based on the provided logging level and format options. +func GetLoggingHandler(level string, pretty, json bool) slog.Handler { var logLevel = new(slog.LevelVar) switch strings.ToLower(level) { @@ -46,6 +46,13 @@ func SetupLogging(level string, pretty, json bool) { handler = slog.NewTextHandler(output, opts) } + return handler +} + +// SetupLogging initializes the global logger with the given level and format +func SetupLogging(level string, pretty, json bool) { + handler := GetLoggingHandler(level, pretty, json) + logger := slog.New(handler) slog.SetDefault(logger) diff --git a/internal/lowlevel/mikrotik.go b/internal/lowlevel/mikrotik.go new file mode 100644 index 0000000..49ef1d7 --- /dev/null +++ b/internal/lowlevel/mikrotik.go @@ -0,0 +1,435 @@ +package lowlevel + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/h44z/wg-portal/internal" + "github.com/h44z/wg-portal/internal/config" +) + +// region models + +const ( + MikrotikApiStatusOk = "success" + MikrotikApiStatusError = "error" +) + +const ( + MikrotikApiErrorCodeUnknown = iota + 600 + MikrotikApiErrorCodeRequestPreparationFailed + MikrotikApiErrorCodeRequestFailed + MikrotikApiErrorCodeResponseDecodeFailed +) + +type MikrotikApiResponse[T any] struct { + Status string + Code int + Data T `json:"data,omitempty"` + Error *MikrotikApiError `json:"error,omitempty"` +} + +type MikrotikApiError struct { + Code int `json:"error,omitempty"` + Message string `json:"message,omitempty"` + Details string `json:"detail,omitempty"` +} + +func (e *MikrotikApiError) String() string { + if e == nil { + return "no error" + } + return fmt.Sprintf("API error %d: %s - %s", e.Code, e.Message, e.Details) +} + +type GenericJsonObject map[string]any +type EmptyResponse struct{} + +func (JsonObject GenericJsonObject) GetString(key string) string { + if value, ok := JsonObject[key]; ok { + if strValue, ok := value.(string); ok { + return strValue + } else { + return fmt.Sprintf("%v", value) // Convert to string if not already + } + } + return "" +} + +func (JsonObject GenericJsonObject) GetInt(key string) int { + if value, ok := JsonObject[key]; ok { + if intValue, ok := value.(int); ok { + return intValue + } else { + if floatValue, ok := value.(float64); ok { + return int(floatValue) // Convert float64 to int + } + if strValue, ok := value.(string); ok { + if intValue, err := strconv.Atoi(strValue); err == nil { + return intValue // Convert string to int if possible + } + } + } + } + return 0 +} + +func (JsonObject GenericJsonObject) GetBool(key string) bool { + if value, ok := JsonObject[key]; ok { + if boolValue, ok := value.(bool); ok { + return boolValue + } else { + if intValue, ok := value.(int); ok { + return intValue == 1 // Convert int to bool (1 is true, 0 is false) + } + if floatValue, ok := value.(float64); ok { + return int(floatValue) == 1 // Convert float64 to bool (1.0 is true, 0.0 is false) + } + if strValue, ok := value.(string); ok { + boolValue, err := strconv.ParseBool(strValue) + if err == nil { + return boolValue + } + } + } + } + return false +} + +type MikrotikRequestOptions struct { + Filters map[string]string `json:"filters,omitempty"` + PropList []string `json:"proplist,omitempty"` +} + +func (o *MikrotikRequestOptions) GetPath(base string) string { + if o == nil { + return base + } + + path, err := url.Parse(base) + if err != nil { + return base + } + + query := path.Query() + for k, v := range o.Filters { + query.Set(k, v) + } + if len(o.PropList) > 0 { + query.Set(".proplist", strings.Join(o.PropList, ",")) + } + path.RawQuery = query.Encode() + return path.String() +} + +// region models + +// region API-client + +type MikrotikApiClient struct { + coreCfg *config.Config + cfg *config.BackendMikrotik + + client *http.Client + log *slog.Logger +} + +func NewMikrotikApiClient(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikApiClient, error) { + c := &MikrotikApiClient{ + coreCfg: coreCfg, + cfg: cfg, + } + + err := c.setup() + if err != nil { + return nil, err + } + + c.debugLog("Mikrotik api client created", "api_url", cfg.ApiUrl) + + return c, nil +} + +func (m *MikrotikApiClient) setup() error { + m.client = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: !m.cfg.ApiVerifyTls, + }, + }, + Timeout: m.cfg.GetApiTimeout(), + } + + if m.cfg.Debug { + m.log = slog.New(internal.GetLoggingHandler("debug", + m.coreCfg.Advanced.LogPretty, + m.coreCfg.Advanced.LogJson). + WithAttrs([]slog.Attr{ + { + Key: "mikrotik-bid", Value: slog.StringValue(m.cfg.Id), + }, + })) + } + + return nil +} + +func (m *MikrotikApiClient) debugLog(msg string, args ...any) { + if m.log != nil { + m.log.Debug("[MT-API] "+msg, args...) + } +} + +func (m *MikrotikApiClient) getFullPath(command string) string { + path, err := url.JoinPath(m.cfg.ApiUrl, command) + if err != nil { + return "" + } + return path +} + +func (m *MikrotikApiClient) prepareGetRequest(ctx context.Context, fullUrl string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullUrl, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + if m.cfg.ApiUser != "" && m.cfg.ApiPassword != "" { + req.SetBasicAuth(m.cfg.ApiUser, m.cfg.ApiPassword) + } + + return req, nil +} + +func (m *MikrotikApiClient) prepareDeleteRequest(ctx context.Context, fullUrl string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, fullUrl, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + if m.cfg.ApiUser != "" && m.cfg.ApiPassword != "" { + req.SetBasicAuth(m.cfg.ApiUser, m.cfg.ApiPassword) + } + + return req, nil +} + +func (m *MikrotikApiClient) preparePayloadRequest( + ctx context.Context, + method string, + fullUrl string, + payload GenericJsonObject, +) (*http.Request, error) { + // marshal the payload to JSON + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, method, fullUrl, bytes.NewReader(payloadBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + if m.cfg.ApiUser != "" && m.cfg.ApiPassword != "" { + req.SetBasicAuth(m.cfg.ApiUser, m.cfg.ApiPassword) + } + + return req, nil +} + +func errToApiResponse[T any](code int, message string, err error) MikrotikApiResponse[T] { + return MikrotikApiResponse[T]{ + Status: MikrotikApiStatusError, + Code: code, + Error: &MikrotikApiError{ + Code: code, + Message: message, + Details: err.Error(), + }, + } +} + +func parseHttpResponse[T any](resp *http.Response, err error) MikrotikApiResponse[T] { + if err != nil { + return errToApiResponse[T](MikrotikApiErrorCodeRequestFailed, "failed to execute request", err) + } + + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + slog.Error("failed to close response body", "error", err) + } + }(resp.Body) + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + var data T + + // if the type of T is EmptyResponse, we can return an empty response with just the status + if _, ok := any(data).(EmptyResponse); ok { + return MikrotikApiResponse[T]{Status: MikrotikApiStatusOk, Code: resp.StatusCode} + } + + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return errToApiResponse[T](MikrotikApiErrorCodeResponseDecodeFailed, "failed to decode response", err) + } + return MikrotikApiResponse[T]{Status: MikrotikApiStatusOk, Code: resp.StatusCode, Data: data} + } + + var apiErr MikrotikApiError + if err := json.NewDecoder(resp.Body).Decode(&apiErr); err != nil { + return errToApiResponse[T](resp.StatusCode, "unknown error, unparsable response", err) + } else { + return MikrotikApiResponse[T]{Status: MikrotikApiStatusError, Code: resp.StatusCode, Error: &apiErr} + } +} + +func (m *MikrotikApiClient) Query( + ctx context.Context, + command string, + opts *MikrotikRequestOptions, +) MikrotikApiResponse[[]GenericJsonObject] { + apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout()) + defer cancel() + + fullUrl := opts.GetPath(m.getFullPath(command)) + + req, err := m.prepareGetRequest(apiCtx, fullUrl) + if err != nil { + return errToApiResponse[[]GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed, + "failed to create request", err) + } + + start := time.Now() + m.debugLog("executing API query", "url", fullUrl) + response := parseHttpResponse[[]GenericJsonObject](m.client.Do(req)) + m.debugLog("retrieved API query result", "url", fullUrl, "duration", time.Since(start).String()) + return response +} + +func (m *MikrotikApiClient) Get( + ctx context.Context, + command string, + opts *MikrotikRequestOptions, +) MikrotikApiResponse[GenericJsonObject] { + apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout()) + defer cancel() + + fullUrl := opts.GetPath(m.getFullPath(command)) + + req, err := m.prepareGetRequest(apiCtx, fullUrl) + if err != nil { + return errToApiResponse[GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed, + "failed to create request", err) + } + + start := time.Now() + m.debugLog("executing API get", "url", fullUrl) + response := parseHttpResponse[GenericJsonObject](m.client.Do(req)) + m.debugLog("retrieved API get result", "url", fullUrl, "duration", time.Since(start).String()) + return response +} + +func (m *MikrotikApiClient) Create( + ctx context.Context, + command string, + payload GenericJsonObject, +) MikrotikApiResponse[GenericJsonObject] { + apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout()) + defer cancel() + + fullUrl := m.getFullPath(command) + + req, err := m.preparePayloadRequest(apiCtx, http.MethodPut, fullUrl, payload) + if err != nil { + return errToApiResponse[GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed, + "failed to create request", err) + } + + start := time.Now() + m.debugLog("executing API put", "url", fullUrl) + response := parseHttpResponse[GenericJsonObject](m.client.Do(req)) + m.debugLog("retrieved API put result", "url", fullUrl, "duration", time.Since(start).String()) + return response +} + +func (m *MikrotikApiClient) Update( + ctx context.Context, + command string, + payload GenericJsonObject, +) MikrotikApiResponse[GenericJsonObject] { + apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout()) + defer cancel() + + fullUrl := m.getFullPath(command) + + req, err := m.preparePayloadRequest(apiCtx, http.MethodPatch, fullUrl, payload) + if err != nil { + return errToApiResponse[GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed, + "failed to create request", err) + } + + start := time.Now() + m.debugLog("executing API patch", "url", fullUrl) + response := parseHttpResponse[GenericJsonObject](m.client.Do(req)) + m.debugLog("retrieved API patch result", "url", fullUrl, "duration", time.Since(start).String()) + return response +} + +func (m *MikrotikApiClient) Delete( + ctx context.Context, + command string, +) MikrotikApiResponse[EmptyResponse] { + apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout()) + defer cancel() + + fullUrl := m.getFullPath(command) + + req, err := m.prepareDeleteRequest(apiCtx, fullUrl) + if err != nil { + return errToApiResponse[EmptyResponse](MikrotikApiErrorCodeRequestPreparationFailed, + "failed to create request", err) + } + + start := time.Now() + m.debugLog("executing API delete", "url", fullUrl) + response := parseHttpResponse[EmptyResponse](m.client.Do(req)) + m.debugLog("retrieved API delete result", "url", fullUrl, "duration", time.Since(start).String()) + return response +} + +func (m *MikrotikApiClient) ExecList( + ctx context.Context, + command string, + payload GenericJsonObject, +) MikrotikApiResponse[[]GenericJsonObject] { + apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout()) + defer cancel() + + fullUrl := m.getFullPath(command) + + req, err := m.preparePayloadRequest(apiCtx, http.MethodPost, fullUrl, payload) + if err != nil { + return errToApiResponse[[]GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed, + "failed to create request", err) + } + + start := time.Now() + m.debugLog("executing API post", "url", fullUrl) + response := parseHttpResponse[[]GenericJsonObject](m.client.Do(req)) + m.debugLog("retrieved API post result", "url", fullUrl, "duration", time.Since(start).String()) + return response +} + +// endregion API-client diff --git a/mkdocs.yml b/mkdocs.yml index f7b5169..7184e3c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -80,6 +80,7 @@ nav: - Examples: documentation/configuration/examples.md - Usage: - General: documentation/usage/general.md + - Backends: documentation/usage/backends.md - LDAP: documentation/usage/ldap.md - Security: documentation/usage/security.md - Webhooks: documentation/usage/webhooks.md From b099e8abfa734f5c646df5b7dd60a5a3452b5ef6 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Sat, 9 Aug 2025 15:55:29 +0200 Subject: [PATCH 31/69] ensure that v2 (or just 2) tags are only published for stable releases (#493) --- .github/workflows/docker-publish.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6b76124..9da67f1 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -66,10 +66,6 @@ jobs: type=semver,pattern={{major}} type=semver,pattern=v{{major}}.{{minor}} type=semver,pattern=v{{major}} - # add v{{major}} tag, even for beta or release-canidate releases - type=match,pattern=(v\d),group=1,enable=${{ contains(github.ref, 'beta') || contains(github.ref, 'rc') }} - # add {{major}} tag, even for beta releases or release-canidate releases - type=match,pattern=v(\d),group=1,enable=${{ contains(github.ref, 'beta') || contains(github.ref, 'rc') }} - name: Build and push Docker image uses: docker/build-push-action@v6 From 9884d8c002e6b050c2c1546fabdaa5248706fbf1 Mon Sep 17 00:00:00 2001 From: h44z Date: Mon, 11 Aug 2025 19:05:33 +0200 Subject: [PATCH 32/69] fix migration tool (#495) (#496) --- internal/app/migrate_v1.go | 46 ++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/internal/app/migrate_v1.go b/internal/app/migrate_v1.go index 9eb323e..9853f40 100644 --- a/internal/app/migrate_v1.go +++ b/internal/app/migrate_v1.go @@ -47,11 +47,18 @@ func migrateFromV1(db *gorm.DB, source, typ string) error { } latestVersion := "1.0.9" if lastVersion.Version != latestVersion { - return fmt.Errorf("unsupported old version, update to database version %s first: %w", latestVersion, err) + return fmt.Errorf("unsupported old version, update to database version %s first", latestVersion) } slog.Info("found valid V1 database", "version", lastVersion.Version) + // validate target database + if err := validateTargetDatabase(db); err != nil { + return fmt.Errorf("target database validation failed: %w", err) + } + + slog.Info("found valid target database, starting migration...") + if err := migrateV1Users(oldDb, db); err != nil { return fmt.Errorf("user migration failed: %w", err) } @@ -70,6 +77,36 @@ func migrateFromV1(db *gorm.DB, source, typ string) error { return nil } +// validateTargetDatabase checks if the target database is empty and ready for migration. +func validateTargetDatabase(db *gorm.DB) error { + var count int64 + err := db.Model(&domain.User{}).Count(&count).Error + if err != nil { + return fmt.Errorf("failed to check user table: %w", err) + } + if count > 0 { + return fmt.Errorf("target database contains %d users, please use an empty database for migration", count) + } + + err = db.Model(&domain.Interface{}).Count(&count).Error + if err != nil { + return fmt.Errorf("failed to check interface table: %w", err) + } + if count > 0 { + return fmt.Errorf("target database contains %d interfaces, please use an empty database for migration", count) + } + + err = db.Model(&domain.Peer{}).Count(&count).Error + if err != nil { + return fmt.Errorf("failed to check peer table: %w", err) + } + if count > 0 { + return fmt.Errorf("target database contains %d peers, please use an empty database for migration", count) + } + + return nil +} + func migrateV1Users(oldDb, newDb *gorm.DB) error { type User struct { Email string `gorm:"primaryKey"` @@ -123,7 +160,7 @@ func migrateV1Users(oldDb, newDb *gorm.DB) error { LinkedPeerCount: 0, } - if err := newDb.Save(&newUser).Error; err != nil { + if err := newDb.Create(&newUser).Error; err != nil { return fmt.Errorf("failed to migrate user %s: %w", oldUser.Email, err) } @@ -217,7 +254,8 @@ func migrateV1Interfaces(oldDb, newDb *gorm.DB) error { PeerDefPostDown: "", } - if err := newDb.Save(&newInterface).Error; err != nil { + // Create new interface with associations + if err := newDb.Create(&newInterface).Error; err != nil { return fmt.Errorf("failed to migrate device %s: %w", oldDevice.DeviceName, err) } @@ -362,7 +400,7 @@ func migrateV1Peers(oldDb, newDb *gorm.DB) error { }, } - if err := newDb.Save(&newPeer).Error; err != nil { + if err := newDb.Create(&newPeer).Error; err != nil { return fmt.Errorf("failed to migrate peer %s (%s): %w", oldPeer.Identifier, oldPeer.PublicKey, err) } From 99df4ca3cd569984314e3c383421411df9674d41 Mon Sep 17 00:00:00 2001 From: h44z Date: Tue, 12 Aug 2025 21:47:04 +0200 Subject: [PATCH 33/69] ensure hooks run after restart (#494) (#497) --- internal/app/wireguard/wireguard_interfaces.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/app/wireguard/wireguard_interfaces.go b/internal/app/wireguard/wireguard_interfaces.go index 22b6658..1a09822 100644 --- a/internal/app/wireguard/wireguard_interfaces.go +++ b/internal/app/wireguard/wireguard_interfaces.go @@ -225,6 +225,15 @@ func (m Manager) RestoreInterfaceState( if err != nil && !iface.IsDisabled() { slog.Debug("creating missing interface", "interface", iface.Identifier) + // temporarily disable interface in database so that the current state is reflected correctly + _ = m.db.SaveInterface(ctx, iface.Identifier, + func(in *domain.Interface) (*domain.Interface, error) { + now := time.Now() + in.Disabled = &now // set + in.DisabledReason = domain.DisabledReasonInterfaceMissing + return in, nil + }) + // try to create a new interface _, err = m.saveInterface(ctx, &iface) if err != nil { From 708c558211ff213fca63555f0da3923d46eadbb8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:47:15 +0200 Subject: [PATCH 34/69] chore(deps): bump github.com/go-pkgz/routegroup in the patch group (#498) Bumps the patch group with 1 update: [github.com/go-pkgz/routegroup](https://github.com/go-pkgz/routegroup). Updates `github.com/go-pkgz/routegroup` from 1.5.1 to 1.5.2 - [Release notes](https://github.com/go-pkgz/routegroup/releases) - [Commits](https://github.com/go-pkgz/routegroup/compare/v1.5.1...v1.5.2) --- updated-dependencies: - dependency-name: github.com/go-pkgz/routegroup dependency-version: 1.5.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 47fb6a8..1034df3 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/coreos/go-oidc/v3 v3.15.0 github.com/glebarez/sqlite v1.11.0 github.com/go-ldap/ldap/v3 v3.4.11 - github.com/go-pkgz/routegroup v1.5.1 + github.com/go-pkgz/routegroup v1.5.2 github.com/go-playground/validator/v10 v10.27.0 github.com/go-webauthn/webauthn v0.13.4 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 33083d4..009395b 100644 --- a/go.sum +++ b/go.sum @@ -70,8 +70,8 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= -github.com/go-pkgz/routegroup v1.5.1 h1:hwVU4w2ltMQXIGEM4WIM0aWyRn7FsZbfbZIlPH7f1Rk= -github.com/go-pkgz/routegroup v1.5.1/go.mod h1:kDDPDRLRiRY1vnENrZJw1jQAzQX7fvsbsHGRQFNQfKc= +github.com/go-pkgz/routegroup v1.5.2 h1:/W/5GwsHaojeEBldiSB/fcqPLm0AE/eT36reCsMEbtY= +github.com/go-pkgz/routegroup v1.5.2/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= From 41cab5f7ea6bfc4671bae6b789ffa84789308c37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:47:25 +0200 Subject: [PATCH 35/69] chore(deps): bump the actions group with 2 updates (#499) Bumps the actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/download-artifact](https://github.com/actions/download-artifact). Updates `actions/checkout` from 4 to 5 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) Updates `actions/download-artifact` from 4 to 5 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/download-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/chart.yml | 4 ++-- .github/workflows/docker-publish.yml | 4 ++-- .github/workflows/pages.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/chart.yml b/.github/workflows/chart.yml index 68a9b0b..366cd74 100644 --- a/.github/workflows/chart.yml +++ b/.github/workflows/chart.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.event_name == 'pull_request' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 @@ -60,7 +60,7 @@ jobs: permissions: packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: docker/login-action@v3 with: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 9da67f1..bc3c7da 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -110,7 +110,7 @@ jobs: contents: write steps: - name: Download binaries - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: binaries diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b148619..fe9d729 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -15,7 +15,7 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 From 098a9fe23e9221331a5ae164beb618ebf7a5e23c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 19:14:17 +0200 Subject: [PATCH 36/69] chore(deps): bump golang from 1.24-alpine to 1.25-alpine (#505) Bumps golang from 1.24-alpine to 1.25-alpine. --- updated-dependencies: - dependency-name: golang dependency-version: 1.25-alpine dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f850ece..b264f97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ RUN npm run build ###### # Build backend ###### -FROM --platform=${BUILDPLATFORM} golang:1.24-alpine AS builder +FROM --platform=${BUILDPLATFORM} golang:1.25-alpine AS builder # Set the working directory WORKDIR /build # Download dependencies From c79a6c83a83027efc43041740b246b9133ab0620 Mon Sep 17 00:00:00 2001 From: h44z Date: Wed, 3 Sep 2025 19:34:58 +0200 Subject: [PATCH 37/69] allow setting the DisplayName property for newly provisioned peers (#507) (#511) --- docs/documentation/rest-api/swagger.yaml | 6 +++++ .../app/api/core/assets/doc/v0_swagger.json | 22 +++++++++++++++++++ .../app/api/core/assets/doc/v0_swagger.yaml | 15 +++++++++++++ .../app/api/core/assets/doc/v1_swagger.json | 5 +++++ .../app/api/core/assets/doc/v1_swagger.yaml | 6 +++++ .../api/v1/backend/provisioning_service.go | 6 ++++- .../app/api/v1/models/models_provisioning.go | 4 ++++ 7 files changed, 63 insertions(+), 1 deletion(-) diff --git a/docs/documentation/rest-api/swagger.yaml b/docs/documentation/rest-api/swagger.yaml index fb6cd00..12ef021 100644 --- a/docs/documentation/rest-api/swagger.yaml +++ b/docs/documentation/rest-api/swagger.yaml @@ -403,6 +403,12 @@ definitions: type: object models.ProvisioningRequest: properties: + DisplayName: + description: |- + DisplayName is an optional name for the new peer. + If unset, a default template value (e.g., "API Peer ...") will be assigned. + example: API Peer xyz + type: string InterfaceIdentifier: description: InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to. example: wg0 diff --git a/internal/app/api/core/assets/doc/v0_swagger.json b/internal/app/api/core/assets/doc/v0_swagger.json index d4c39d1..32821a8 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.json +++ b/internal/app/api/core/assets/doc/v0_swagger.json @@ -1781,6 +1781,11 @@ "type": "string" } }, + "Backend": { + "description": "the backend used for this interface e.g., local, mikrotik, ...", + "type": "string", + "example": "local" + }, "Disabled": { "description": "flag that specifies if the interface is enabled (up) or not (down)", "type": "boolean" @@ -2249,6 +2254,12 @@ "ApiAdminOnly": { "type": "boolean" }, + "AvailableBackends": { + "type": "array", + "items": { + "$ref": "#/definitions/model.SettingsBackendNames" + } + }, "LoginFormVisible": { "type": "boolean" }, @@ -2269,6 +2280,17 @@ } } }, + "model.SettingsBackendNames": { + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "Name": { + "type": "string" + } + } + }, "model.User": { "type": "object", "properties": { diff --git a/internal/app/api/core/assets/doc/v0_swagger.yaml b/internal/app/api/core/assets/doc/v0_swagger.yaml index bb0de5f..6a28b99 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.yaml +++ b/internal/app/api/core/assets/doc/v0_swagger.yaml @@ -65,6 +65,10 @@ definitions: items: type: string type: array + Backend: + description: the backend used for this interface e.g., local, mikrotik, ... + example: local + type: string Disabled: description: flag that specifies if the interface is enabled (up) or not (down) type: boolean @@ -381,6 +385,10 @@ definitions: properties: ApiAdminOnly: type: boolean + AvailableBackends: + items: + $ref: '#/definitions/model.SettingsBackendNames' + type: array LoginFormVisible: type: boolean MailLinkOnly: @@ -394,6 +402,13 @@ definitions: WebAuthnEnabled: type: boolean type: object + model.SettingsBackendNames: + properties: + Id: + type: string + Name: + type: string + type: object model.User: properties: ApiEnabled: diff --git a/internal/app/api/core/assets/doc/v1_swagger.json b/internal/app/api/core/assets/doc/v1_swagger.json index c479a70..9d363dd 100644 --- a/internal/app/api/core/assets/doc/v1_swagger.json +++ b/internal/app/api/core/assets/doc/v1_swagger.json @@ -2086,6 +2086,11 @@ "InterfaceIdentifier" ], "properties": { + "DisplayName": { + "description": "DisplayName is an optional name for the new peer.\nIf unset, a default template value (e.g., \"API Peer ...\") will be assigned.", + "type": "string", + "example": "API Peer xyz" + }, "InterfaceIdentifier": { "description": "InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to.", "type": "string", diff --git a/internal/app/api/core/assets/doc/v1_swagger.yaml b/internal/app/api/core/assets/doc/v1_swagger.yaml index 295bd79..e535ef4 100644 --- a/internal/app/api/core/assets/doc/v1_swagger.yaml +++ b/internal/app/api/core/assets/doc/v1_swagger.yaml @@ -445,6 +445,12 @@ definitions: type: object models.ProvisioningRequest: properties: + DisplayName: + description: |- + DisplayName is an optional name for the new peer. + If unset, a default template value (e.g., "API Peer ...") will be assigned. + example: API Peer xyz + type: string InterfaceIdentifier: description: InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to. diff --git a/internal/app/api/v1/backend/provisioning_service.go b/internal/app/api/v1/backend/provisioning_service.go index 88bbfee..1c609c0 100644 --- a/internal/app/api/v1/backend/provisioning_service.go +++ b/internal/app/api/v1/backend/provisioning_service.go @@ -162,7 +162,11 @@ func (p ProvisioningService) NewPeer(ctx context.Context, req models.Provisionin if req.PresharedKey != "" { peer.PresharedKey = domain.PreSharedKey(req.PresharedKey) } - peer.GenerateDisplayName("API") + if req.DisplayName == "" { + peer.GenerateDisplayName("API") + } else { + peer.DisplayName = req.DisplayName + } // save new peer peer, err = p.peers.CreatePeer(ctx, peer) diff --git a/internal/app/api/v1/models/models_provisioning.go b/internal/app/api/v1/models/models_provisioning.go index 88bd0eb..35b6a1b 100644 --- a/internal/app/api/v1/models/models_provisioning.go +++ b/internal/app/api/v1/models/models_provisioning.go @@ -68,6 +68,10 @@ type ProvisioningRequest struct { // If no user identifier is set, the authenticated user is used. UserIdentifier string `json:"UserIdentifier" example:"uid-1234567"` + // DisplayName is an optional name for the new peer. + // If unset, a default template value (e.g., "API Peer ...") will be assigned. + DisplayName string `json:"DisplayName" example:"API Peer xyz" binding:"omitempty"` + // PublicKey is the optional public key of the peer. If no public key is set, a new key pair is generated. PublicKey string `json:"PublicKey" example:"xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=" binding:"omitempty,len=44"` // PresharedKey is the optional pre-shared key of the peer. If no pre-shared key is set, a new key is generated. From 0cbca61c15b970148978b0f8a175c09d9043fc3a Mon Sep 17 00:00:00 2001 From: h44z Date: Wed, 3 Sep 2025 19:37:34 +0200 Subject: [PATCH 38/69] ensure that LDAP filter values are escaped (#512) --- internal/app/auth/auth_ldap.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/app/auth/auth_ldap.go b/internal/app/auth/auth_ldap.go index 64122f2..d75c3b6 100644 --- a/internal/app/auth/auth_ldap.go +++ b/internal/app/auth/auth_ldap.go @@ -54,7 +54,7 @@ func (l LdapAuthenticator) PlaintextAuthentication(userId domain.UserIdentifier, attrs := []string{"dn"} - loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", string(userId), -1) + loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", ldap.EscapeFilter(string(userId)), -1) searchRequest := ldap.NewSearchRequest( l.cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit @@ -100,7 +100,7 @@ func (l LdapAuthenticator) GetUserInfo(_ context.Context, userId domain.UserIden attrs := internal.LdapSearchAttributes(&l.cfg.FieldMap) - loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", string(userId), -1) + loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", ldap.EscapeFilter(string(userId)), -1) searchRequest := ldap.NewSearchRequest( l.cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit From d311313cb4823736a27a877c6e502d357843eaae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 20:17:13 +0200 Subject: [PATCH 39/69] chore(deps): bump the patch group across 1 directory with 2 updates (#510) Bumps the patch group with 2 updates in the / directory: [github.com/go-pkgz/routegroup](https://github.com/go-pkgz/routegroup) and [gorm.io/gorm](https://github.com/go-gorm/gorm). Updates `github.com/go-pkgz/routegroup` from 1.5.2 to 1.5.3 - [Release notes](https://github.com/go-pkgz/routegroup/releases) - [Commits](https://github.com/go-pkgz/routegroup/compare/v1.5.2...v1.5.3) Updates `gorm.io/gorm` from 1.30.1 to 1.30.2 - [Release notes](https://github.com/go-gorm/gorm/releases) - [Commits](https://github.com/go-gorm/gorm/compare/v1.30.1...v1.30.2) --- updated-dependencies: - dependency-name: github.com/go-pkgz/routegroup dependency-version: 1.5.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch - dependency-name: gorm.io/gorm dependency-version: 1.30.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 1034df3..2603f03 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/coreos/go-oidc/v3 v3.15.0 github.com/glebarez/sqlite v1.11.0 github.com/go-ldap/ldap/v3 v3.4.11 - github.com/go-pkgz/routegroup v1.5.2 + github.com/go-pkgz/routegroup v1.5.3 github.com/go-playground/validator/v10 v10.27.0 github.com/go-webauthn/webauthn v0.13.4 github.com/google/uuid v1.6.0 @@ -29,7 +29,7 @@ require ( gorm.io/driver/mysql v1.6.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlserver v1.6.1 - gorm.io/gorm v1.30.1 + gorm.io/gorm v1.30.2 ) require ( diff --git a/go.sum b/go.sum index 009395b..2657a79 100644 --- a/go.sum +++ b/go.sum @@ -70,8 +70,8 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= -github.com/go-pkgz/routegroup v1.5.2 h1:/W/5GwsHaojeEBldiSB/fcqPLm0AE/eT36reCsMEbtY= -github.com/go-pkgz/routegroup v1.5.2/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg= +github.com/go-pkgz/routegroup v1.5.3 h1:IvH1KLcQkMap9jucQGBlef3IBloxSAe8USUFvxShFqs= +github.com/go-pkgz/routegroup v1.5.3/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -374,8 +374,8 @@ gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXD gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY= gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= -gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= -gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/gorm v1.30.2 h1:f7bevlVoVe4Byu3pmbWPVHnPsLoWaMjEb7/clyr9Ivs= +gorm.io/gorm v1.30.2/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I= modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= From b61d84ec4f30ac33513b2cf3f1db45b2a34b7621 Mon Sep 17 00:00:00 2001 From: Victor LEFEBVRE Date: Mon, 8 Sep 2025 10:39:10 +0200 Subject: [PATCH 40/69] allow disabling local admin user (#515) --- internal/app/app.go | 8 ++++++-- internal/config/config.go | 8 +++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index e33cfc0..eda1b54 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -52,8 +52,12 @@ func Initialize( // Switch to admin user context startupContext = domain.SetUserInfo(startupContext, domain.SystemAdminContextUserInfo()) - if err := a.createDefaultUser(startupContext); err != nil { - return fmt.Errorf("failed to create default user: %w", err) + if !cfg.Core.AdminUserDisabled { + if err := a.createDefaultUser(startupContext); err != nil { + return fmt.Errorf("failed to create default user: %w", err) + } + } else { + slog.Info("Local Admin user disabled!") } if err := a.importNewInterfaces(startupContext); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 0574133..4203099 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,9 +14,10 @@ import ( type Config struct { Core struct { // AdminUser defines the default administrator account that will be created - AdminUser string `yaml:"admin_user"` - AdminPassword string `yaml:"admin_password"` - AdminApiToken string `yaml:"admin_api_token"` // if set, the API access is enabled automatically + AdminUserDisabled bool `yaml:"disable_admin_user"` + AdminUser string `yaml:"admin_user"` + AdminPassword string `yaml:"admin_password"` + AdminApiToken string `yaml:"admin_api_token"` // if set, the API access is enabled automatically EditableKeys bool `yaml:"editable_keys"` CreateDefaultPeer bool `yaml:"create_default_peer"` @@ -113,6 +114,7 @@ func (c *Config) LogStartupValues() { func defaultConfig() *Config { cfg := &Config{} + cfg.Core.AdminUserDisabled = false cfg.Core.AdminUser = "admin@wgportal.local" cfg.Core.AdminPassword = "wgportal-default" cfg.Core.AdminApiToken = "" // by default, the API access is disabled From db357b82d022d7f0175573b25ac2a4ce03782ca5 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Mon, 8 Sep 2025 19:16:52 +0200 Subject: [PATCH 41/69] update doc for disable_admin_user flag (#515) --- docs/documentation/configuration/overview.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 1268582..7c0000f 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -16,6 +16,7 @@ core: admin_user: admin@wgportal.local admin_password: wgportal-default admin_api_token: "" + disable_admin_user: false editable_keys: true create_default_peer: false create_default_peer_on_creation: false @@ -131,6 +132,10 @@ More advanced options are found in the subsequent `Advanced` section. - **Description:** The administrator password. The default password should be changed immediately! - **Important:** The password should be strong and secure. The minimum password length is specified in [auth.min_password_length](#min_password_length). By default, it is 16 characters. +### `disable_admin_user` +- **Default:** `false` +- **Description:** If `true`, no admin user is created. This is useful if you plan to manage users exclusively through external authentication providers such as LDAP or OAuth. + ### `admin_api_token` - **Default:** *(empty)* - **Description:** An API token for the admin user. If a token is provided, the REST API can be accessed using this token. If empty, the API is initially disabled for the admin user. From 891d499a18b0a372b3fc888138b680eddbb4227e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 19:24:25 +0200 Subject: [PATCH 42/69] chore(deps): bump actions/setup-python from 5 to 6 in the actions group (#520) Bumps the actions group with 1 update: [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/setup-python` from 5 to 6 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/chart.yml | 2 +- .github/workflows/pages.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/chart.yml b/.github/workflows/chart.yml index 366cd74..cc959b5 100644 --- a/.github/workflows/chart.yml +++ b/.github/workflows/chart.yml @@ -35,7 +35,7 @@ jobs: # ct lint requires Python 3.x to run following packages: # - yamale (https://github.com/23andMe/Yamale) # - yamllint (https://github.com/adrienverge/yamllint) - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.x' diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index fe9d729..0bc7674 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -19,7 +19,7 @@ jobs: with: fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: 3.x From 6d2a5fa6de67df63f0339294f441fdd790fac8cf Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Mon, 8 Sep 2025 19:25:47 +0200 Subject: [PATCH 43/69] chore: update Go dependencies --- go.mod | 63 +++++++++++++++----------- go.sum | 141 +++++++++++++++++++++++++++++++++------------------------ 2 files changed, 119 insertions(+), 85 deletions(-) diff --git a/go.mod b/go.mod index 2603f03..4de566e 100644 --- a/go.mod +++ b/go.mod @@ -13,23 +13,23 @@ require ( github.com/go-webauthn/webauthn v0.13.4 github.com/google/uuid v1.6.0 github.com/prometheus-community/pro-bing v0.7.0 - github.com/prometheus/client_golang v1.23.0 - github.com/stretchr/testify v1.10.0 + github.com/prometheus/client_golang v1.23.2 + github.com/stretchr/testify v1.11.1 github.com/swaggo/swag v1.16.6 github.com/vardius/message-bus v1.1.5 github.com/vishvananda/netlink v1.3.1 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.41.0 - golang.org/x/oauth2 v0.30.0 - golang.org/x/sys v0.35.0 + golang.org/x/crypto v0.42.0 + golang.org/x/oauth2 v0.31.0 + golang.org/x/sys v0.36.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 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlserver v1.6.1 - gorm.io/gorm v1.30.2 + gorm.io/gorm v1.30.5 ) require ( @@ -41,62 +41,73 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect - github.com/go-jose/go-jose/v4 v4.1.0 // indirect - github.com/go-openapi/jsonpointer v0.21.2 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.2 // indirect + github.com/go-openapi/jsonpointer v0.22.0 // indirect + github.com/go-openapi/jsonreference v0.21.1 // indirect github.com/go-openapi/spec v0.21.0 // indirect - github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-openapi/swag v0.24.1 // indirect + github.com/go-openapi/swag/cmdutils v0.24.0 // indirect + github.com/go-openapi/swag/conv v0.24.0 // indirect + github.com/go-openapi/swag/fileutils v0.24.0 // indirect + github.com/go-openapi/swag/jsonname v0.24.0 // indirect + github.com/go-openapi/swag/jsonutils v0.24.0 // indirect + github.com/go-openapi/swag/loading v0.24.0 // indirect + github.com/go-openapi/swag/mangling v0.24.0 // indirect + github.com/go-openapi/swag/netutils v0.24.0 // indirect + github.com/go-openapi/swag/stringutils v0.24.0 // indirect + github.com/go-openapi/swag/typeutils v0.24.0 // indirect + github.com/go-openapi/swag/yamlutils v0.24.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-test/deep v1.1.1 // indirect - github.com/go-webauthn/x v0.1.23 // indirect - github.com/golang-jwt/jwt/v5 v5.2.3 // indirect + github.com/go-webauthn/x v0.1.24 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-tpm v0.9.5 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.7.5 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/josharian/native v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect - github.com/mdlayher/netlink v1.7.2 // indirect + github.com/mdlayher/netlink v1.8.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect - github.com/microsoft/go-mssqldb v1.9.2 // indirect + github.com/microsoft/go-mssqldb v1.9.3 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.65.0 // indirect - github.com/prometheus/procfs v0.16.1 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.17.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect - golang.org/x/exp v0.0.0-20250808145144-a408d31f581a // indirect - golang.org/x/mod v0.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect + golang.org/x/mod v0.28.0 // indirect golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect golang.org/x/tools v0.36.0 // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect - google.golang.org/protobuf v1.36.6 // indirect - modernc.org/libc v1.66.6 // indirect + google.golang.org/protobuf v1.36.8 // indirect + modernc.org/libc v1.66.8 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.38.2 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 2657a79..a109219 100644 --- a/go.sum +++ b/go.sum @@ -50,26 +50,48 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= 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.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= -github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= +github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= +github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= -github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA= -github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM= +github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU= +github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA= +github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8= github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= -github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= -github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8= +github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A= +github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I= +github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8= +github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik= +github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c= +github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak= +github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90= +github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k= +github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q= +github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts= +github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0= +github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc= +github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk= +github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk= +github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc= +github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w= +github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM= +github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM= +github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w= +github.com/go-openapi/swag/typeutils v0.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw= +github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI= +github.com/go-openapi/swag/yamlutils v0.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c= +github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8= github.com/go-pkgz/routegroup v1.5.3 h1:IvH1KLcQkMap9jucQGBlef3IBloxSAe8USUFvxShFqs= github.com/go-pkgz/routegroup v1.5.3/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -86,18 +108,17 @@ 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-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ= github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI= -github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI= -github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E= +github.com/go-webauthn/x v0.1.24 h1:6LaWf2zzWqbyKT8IyQkhje1/1KCGhlEkMz4V1tDnt/A= +github.com/go-webauthn/x v0.1.24/go.mod h1:2o5XKJ+X1AKqYKGgHdKflGnoQFQZ6flJ2IFCBKSbSOw= 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= -github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= -github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -117,8 +138,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= -github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -139,8 +160,6 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= -github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -160,13 +179,13 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= -github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= -github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM= +github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHid9HK3594= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= -github.com/microsoft/go-mssqldb v1.9.2 h1:nY8TmFMQOHpm2qVWo6y4I2mAmVdZqlGiMGAYt64Ibbs= -github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA= +github.com/microsoft/go-mssqldb v1.9.3 h1:hy4p+LDC8LIGvI3JATnLVmBOLMJbmn5X400mr5j0lPs= +github.com/microsoft/go-mssqldb v1.9.3/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -185,14 +204,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA= github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE= -github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= -github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= @@ -210,8 +229,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= @@ -236,6 +255,10 @@ github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= @@ -248,18 +271,18 @@ 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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/exp v0.0.0-20250808145144-a408d31f581a h1:Y+7uR/b1Mw2iSXZ3G//1haIiSElDQZ8KWh0h+sZPG90= -golang.org/x/exp v0.0.0-20250808145144-a408d31f581a/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= +golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -279,8 +302,8 @@ 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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= +golang.org/x/oauth2 v0.31.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= @@ -288,8 +311,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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.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= @@ -310,8 +333,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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.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= @@ -340,8 +363,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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 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= @@ -355,8 +378,8 @@ golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+Z golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -374,20 +397,20 @@ gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXD gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY= gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= -gorm.io/gorm v1.30.2 h1:f7bevlVoVe4Byu3pmbWPVHnPsLoWaMjEb7/clyr9Ivs= -gorm.io/gorm v1.30.2/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= -modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I= -modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= -modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= -modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= -modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +gorm.io/gorm v1.30.5 h1:dvEfYwxL+i+xgCNSGGBT1lDjCzfELK8fHZxL3Ee9X0s= +gorm.io/gorm v1.30.5/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s= +modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.28 h1:Vp156KUA2nPu9F1NEv036x9UGOjg2qsi5QlWTjZmtMk= +modernc.org/fileutil v1.3.28/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.66.6 h1:RyQpwAhM/19nXD8y3iejM/AjmKwY2TjxZTlUWTsWw2U= -modernc.org/libc v1.66.6/go.mod h1:j8z0EYAuumoMQ3+cWXtmw6m+LYn3qm8dcZDFtFTSq+M= +modernc.org/libc v1.66.8 h1:/awsvTnyN/sNjvJm6S3lb7KZw5WV4ly/sBEG7ZUzmIE= +modernc.org/libc v1.66.8/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -402,5 +425,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= From 765fb0977084bc3a874290265756251f0c205410 Mon Sep 17 00:00:00 2001 From: h44z Date: Tue, 9 Sep 2025 21:43:16 +0200 Subject: [PATCH 44/69] Mikrotik improvements (#521) * allow to specify ignored interfaces (#514) * only set endpoint info for "responder" peers (#516) --- docs/documentation/configuration/examples.md | 20 +++ docs/documentation/configuration/overview.md | 10 ++ internal/adapters/wgcontroller/mikrotik.go | 17 +-- internal/app/wireguard/controller_manager.go | 19 +-- .../app/wireguard/wireguard_interfaces.go | 115 ++++++++++-------- internal/config/backend.go | 8 ++ internal/domain/peer.go | 2 +- 7 files changed, 125 insertions(+), 66 deletions(-) diff --git a/docs/documentation/configuration/examples.md b/docs/documentation/configuration/examples.md index 41a4034..3909d48 100644 --- a/docs/documentation/configuration/examples.md +++ b/docs/documentation/configuration/examples.md @@ -11,6 +11,24 @@ core: create_default_peer: true self_provisioning_allowed: true +backend: + # default backend decides where new interfaces are created + default: mikrotik + + mikrotik: + - id: mikrotik # unique id, not "local" + display_name: RouterOS RB5009 # optional nice name + api_url: https://10.10.10.10/rest + api_user: wgportal + api_password: a-super-secret-password + api_verify_tls: false # set to false only if using self-signed during testing + api_timeout: 30s # maximum request duration + concurrency: 5 # limit parallel REST calls to device + debug: false # verbose logging for this backend + ignored_interfaces: # ignore these interfaces during import + - wgTest1 + - wgTest2 + web: site_title: My WireGuard Server site_company_name: My Company @@ -195,3 +213,5 @@ auth: registration_enabled: true log_user_info: true ``` + +For more information, check out the usage documentation (e.g. [General Configuration](../usage/general.md) or [Backends Configuration](../usage/backends.md)). diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 7c0000f..0345338 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -184,6 +184,11 @@ The current MikroTik backend is in **BETA** and may not support all features. - **Description:** The default backend to use for managing WireGuard interfaces. Valid options are: `local`, or other backend id's configured in the `mikrotik` section. +### `ignored_local_interfaces` +- **Default:** *(empty)* +- **Description:** A list of interface names to exclude when enumerating local interfaces. + This is useful if you want to prevent certain interfaces from being imported from the local system. + ### Mikrotik The `mikrotik` array contains a list of MikroTik backend definitions. Each entry describes how to connect to a MikroTik RouterOS instance that hosts WireGuard interfaces. @@ -225,6 +230,11 @@ Below are the properties for each entry inside `backend.mikrotik`: - **Default:** `5` - **Description:** Maximum number of concurrent API requests the backend will issue when enumerating interfaces and their details. If `0` or negative, a sane default of `5` is used. +#### `ignored_interfaces` +- **Default:** *(empty)* +- **Description:** A list of interface names to exclude during interface enumeration. + This is useful if you want to prevent specific interfaces from being imported from the MikroTik device. + #### `debug` - **Default:** `false` - **Description:** Enable verbose debug logging for the MikroTik backend. diff --git a/internal/adapters/wgcontroller/mikrotik.go b/internal/adapters/wgcontroller/mikrotik.go index 8498d34..ac98094 100644 --- a/internal/adapters/wgcontroller/mikrotik.go +++ b/internal/adapters/wgcontroller/mikrotik.go @@ -3,14 +3,13 @@ package wgcontroller import ( "context" "fmt" + "log/slog" "slices" "strconv" "strings" "sync" "time" - "log/slog" - "github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/lowlevel" @@ -678,11 +677,15 @@ func (c *MikrotikController) updatePeer( extras := pp.GetExtras().(domain.MikrotikPeerExtras) peerId := extras.Id - endpoint := pp.Endpoint - endpointPort := "51820" // default port if not set - if s := strings.Split(endpoint, ":"); len(s) == 2 { - endpoint = s[0] - endpointPort = s[1] + endpoint := "" // by default, we have no endpoint (the peer does not initiate a connection) + endpointPort := "0" // by default, we have no endpoint port (the peer does not initiate a connection) + if !extras.IsResponder { // if the peer is not only a responder, it needs the endpoint to initiate a connection + endpoint = pp.Endpoint + endpointPort = "51820" // default port if not set + if s := strings.Split(endpoint, ":"); len(s) == 2 { + endpoint = s[0] + endpointPort = s[1] + } } allowedAddressStr := domain.CidrsToString(pp.AllowedIPs) diff --git a/internal/app/wireguard/controller_manager.go b/internal/app/wireguard/controller_manager.go index ab1eaa9..2eea6af 100644 --- a/internal/app/wireguard/controller_manager.go +++ b/internal/app/wireguard/controller_manager.go @@ -82,8 +82,9 @@ func (c *ControllerManager) registerLocalController() error { c.controllers[config.LocalBackendName] = backendInstance{ Config: config.BackendBase{ - Id: config.LocalBackendName, - DisplayName: "Local WireGuard Controller", + Id: config.LocalBackendName, + DisplayName: "Local WireGuard Controller", + IgnoredInterfaces: c.cfg.Backend.IgnoredLocalInterfaces, }, Implementation: localController, } @@ -118,17 +119,17 @@ func (c *ControllerManager) logRegisteredControllers() { } func (c *ControllerManager) GetControllerByName(backend domain.InterfaceBackend) InterfaceController { - return c.getController(backend, "") + return c.getController(backend, "").Implementation } func (c *ControllerManager) GetController(iface domain.Interface) InterfaceController { - return c.getController(iface.Backend, iface.Identifier) + return c.getController(iface.Backend, iface.Identifier).Implementation } func (c *ControllerManager) getController( backend domain.InterfaceBackend, ifaceId domain.InterfaceIdentifier, -) InterfaceController { +) backendInstance { if backend == "" { // If no backend is specified, use the local controller. // This might be the case for interfaces created in previous WireGuard Portal versions. @@ -145,13 +146,13 @@ func (c *ControllerManager) getController( slog.Warn("controller for backend not found, using local controller", "backend", backend, "interface", ifaceId) } - return controller.Implementation + return controller } -func (c *ControllerManager) GetAllControllers() []InterfaceController { - var backendInstances = make([]InterfaceController, 0, len(c.controllers)) +func (c *ControllerManager) GetAllControllers() []backendInstance { + var backendInstances = make([]backendInstance, 0, len(c.controllers)) for instance := range maps.Values(c.controllers) { - backendInstances = append(backendInstances, instance.Implementation) + backendInstances = append(backendInstances, instance) } return backendInstances } diff --git a/internal/app/wireguard/wireguard_interfaces.go b/internal/app/wireguard/wireguard_interfaces.go index 1a09822..368d1eb 100644 --- a/internal/app/wireguard/wireguard_interfaces.go +++ b/internal/app/wireguard/wireguard_interfaces.go @@ -15,26 +15,6 @@ import ( "github.com/h44z/wg-portal/internal/domain" ) -// GetImportableInterfaces returns all physical interfaces that are available on the system. -// This function also returns interfaces that are already available in the database. -func (m Manager) GetImportableInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) { - if err := domain.ValidateAdminAccessRights(ctx); err != nil { - return nil, err - } - - var allPhysicalInterfaces []domain.PhysicalInterface - for _, wgBackend := range m.wg.GetAllControllers() { - physicalInterfaces, err := wgBackend.GetInterfaces(ctx) - if err != nil { - return nil, err - } - - allPhysicalInterfaces = append(allPhysicalInterfaces, physicalInterfaces...) - } - - return allPhysicalInterfaces, nil -} - // GetInterfaceAndPeers returns the interface and all peers for the given interface identifier. func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) ( *domain.Interface, @@ -110,52 +90,62 @@ func (m Manager) GetUserInterfaces(ctx context.Context, _ domain.UserIdentifier) } // ImportNewInterfaces imports all new physical interfaces that are available on the system. +// If a filter is set, only interfaces that match the filter will be imported. func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.InterfaceIdentifier) (int, error) { if err := domain.ValidateAdminAccessRights(ctx); err != nil { return 0, err } + var existingInterfaceIds []domain.InterfaceIdentifier + existingInterfaces, err := m.db.GetAllInterfaces(ctx) + if err != nil { + return 0, err + } + for _, existingInterface := range existingInterfaces { + existingInterfaceIds = append(existingInterfaceIds, existingInterface.Identifier) + } + imported := 0 for _, wgBackend := range m.wg.GetAllControllers() { - physicalInterfaces, err := wgBackend.GetInterfaces(ctx) + physicalInterfaces, err := wgBackend.Implementation.GetInterfaces(ctx) if err != nil { return 0, err } - // if no filter is given, exclude already existing interfaces - var excludedInterfaces []domain.InterfaceIdentifier - if len(filter) == 0 { - existingInterfaces, err := m.db.GetAllInterfaces(ctx) - if err != nil { - return 0, err - } - for _, existingInterface := range existingInterfaces { - excludedInterfaces = append(excludedInterfaces, existingInterface.Identifier) - } - } - for _, physicalInterface := range physicalInterfaces { - if slices.Contains(excludedInterfaces, physicalInterface.Identifier) { + if slices.Contains(wgBackend.Config.IgnoredInterfaces, string(physicalInterface.Identifier)) { + slog.Info("ignoring interface due to backend filter restrictions", + "interface", physicalInterface.Identifier, "filter", wgBackend.Config.IgnoredInterfaces, + "backend", wgBackend.Config.Id) + continue // skip ignored interfaces + } + + if slices.Contains(existingInterfaceIds, physicalInterface.Identifier) { + continue // skip interfaces that already exist + } + + if len(filter) > 0 && !slices.Contains(filter, physicalInterface.Identifier) { + slog.Info("ignoring interface due to filter restrictions", + "interface", physicalInterface.Identifier, "filter", wgBackend.Config.IgnoredInterfaces, + "backend", wgBackend.Config.Id) continue } - if len(filter) != 0 && !slices.Contains(filter, physicalInterface.Identifier) { - continue - } + slog.Info("importing new interface", + "interface", physicalInterface.Identifier, "backend", wgBackend.Config.Id) - slog.Info("importing new interface", "interface", physicalInterface.Identifier) - - physicalPeers, err := wgBackend.GetPeers(ctx, physicalInterface.Identifier) + physicalPeers, err := wgBackend.Implementation.GetPeers(ctx, physicalInterface.Identifier) if err != nil { return 0, err } - err = m.importInterface(ctx, wgBackend, &physicalInterface, physicalPeers) + err = m.importInterface(ctx, wgBackend.Implementation, &physicalInterface, physicalPeers) if err != nil { return 0, fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err) } - slog.Info("imported new interface", "interface", physicalInterface.Identifier, "peers", len(physicalPeers)) + slog.Info("imported new interface", + "interface", physicalInterface.Identifier, "peers", len(physicalPeers), "backend", wgBackend.Config.Id) imported++ } } @@ -221,9 +211,11 @@ func (m Manager) RestoreInterfaceState( return fmt.Errorf("failed to load peers for %s: %w", iface.Identifier, err) } - _, err = m.wg.GetController(iface).GetInterface(ctx, iface.Identifier) + controller := m.wg.GetController(iface) + + _, err = controller.GetInterface(ctx, iface.Identifier) if err != nil && !iface.IsDisabled() { - slog.Debug("creating missing interface", "interface", iface.Identifier) + slog.Debug("creating missing interface", "interface", iface.Identifier, "backend", controller.GetId()) // temporarily disable interface in database so that the current state is reflected correctly _ = m.db.SaveInterface(ctx, iface.Identifier, @@ -250,7 +242,8 @@ func (m Manager) RestoreInterfaceState( return fmt.Errorf("failed to create physical interface %s: %w", iface.Identifier, err) } } else { - slog.Debug("restoring interface state", "interface", iface.Identifier, "disabled", iface.IsDisabled()) + slog.Debug("restoring interface state", + "interface", iface.Identifier, "disabled", iface.IsDisabled(), "backend", controller.GetId()) // try to move interface to stored state _, err = m.saveInterface(ctx, &iface) @@ -278,13 +271,13 @@ func (m Manager) RestoreInterfaceState( for _, peer := range peers { switch { case iface.IsDisabled() && iface.Backend == config.LocalBackendName: // if interface is disabled, delete all peers - if err := m.wg.GetController(iface).DeletePeer(ctx, iface.Identifier, + if err := controller.DeletePeer(ctx, iface.Identifier, peer.Identifier); err != nil { return fmt.Errorf("failed to remove peer %s for disabled interface %s: %w", peer.Identifier, iface.Identifier, err) } default: // update peer - err := m.wg.GetController(iface).SavePeer(ctx, iface.Identifier, peer.Identifier, + err := controller.SavePeer(ctx, iface.Identifier, peer.Identifier, func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) { domain.MergeToPhysicalPeer(pp, &peer) return pp, nil @@ -297,7 +290,7 @@ func (m Manager) RestoreInterfaceState( } // remove non-wgportal peers - physicalPeers, _ := m.wg.GetController(iface).GetPeers(ctx, iface.Identifier) + physicalPeers, _ := controller.GetPeers(ctx, iface.Identifier) for _, physicalPeer := range physicalPeers { isWgPortalPeer := false for _, peer := range peers { @@ -307,7 +300,7 @@ func (m Manager) RestoreInterfaceState( } } if !isWgPortalPeer { - err := m.wg.GetController(iface).DeletePeer(ctx, iface.Identifier, + err := controller.DeletePeer(ctx, iface.Identifier, domain.PeerIdentifier(physicalPeer.PublicKey)) if err != nil { return fmt.Errorf("failed to remove non-wgportal peer %s from interface %s: %w", @@ -551,6 +544,30 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) ( return nil, fmt.Errorf("failed to save interface: %w", err) } + // update the interface type of peers in db + peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier) + if err != nil { + return nil, fmt.Errorf("failed to load peers for interface %s: %w", iface.Identifier, err) + } + for _, peer := range peers { + err := m.db.SavePeer(ctx, peer.Identifier, func(_ *domain.Peer) (*domain.Peer, error) { + switch iface.Type { + case domain.InterfaceTypeAny: + peer.Interface.Type = domain.InterfaceTypeAny + case domain.InterfaceTypeClient: + peer.Interface.Type = domain.InterfaceTypeServer + case domain.InterfaceTypeServer: + peer.Interface.Type = domain.InterfaceTypeClient + } + + return &peer, nil + }) + if err != nil { + return nil, fmt.Errorf("failed to update peer %s for interface %s: %w", peer.Identifier, + iface.Identifier, err) + } + } + if iface.IsDisabled() { physicalInterface, _ := m.wg.GetController(*iface).GetInterface(ctx, iface.Identifier) fwMark := iface.FirewallMark diff --git a/internal/config/backend.go b/internal/config/backend.go index f81adad..fa8ff2e 100644 --- a/internal/config/backend.go +++ b/internal/config/backend.go @@ -10,6 +10,12 @@ const LocalBackendName = "local" type Backend struct { Default string `yaml:"default"` // The default backend to use (defaults to the internal backend) + // Local Backend-specific configuration + + IgnoredLocalInterfaces []string `yaml:"ignored_local_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0") + + // External Backend-specific configuration + Mikrotik []BackendMikrotik `yaml:"mikrotik"` } @@ -42,6 +48,8 @@ func (b *Backend) Validate() error { type BackendBase struct { Id string `yaml:"id"` // A unique id for the backend DisplayName string `yaml:"display_name"` // A display name for the backend + + IgnoredInterfaces []string `yaml:"ignored_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0") } // GetDisplayName returns the display name of the backend. diff --git a/internal/domain/peer.go b/internal/domain/peer.go index 519d551..c47f63e 100644 --- a/internal/domain/peer.go +++ b/internal/domain/peer.go @@ -328,7 +328,7 @@ func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) { Id: "", Name: p.DisplayName, Comment: p.Notes, - IsResponder: false, + IsResponder: p.Interface.Type == InterfaceTypeClient, Disabled: p.IsDisabled(), ClientEndpoint: p.Endpoint.GetValue(), ClientAddress: CidrsToString(p.Interface.Addresses), From c5fe82ab114cda3b2fce66fb83d61746d6eb37df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 22:48:44 +0200 Subject: [PATCH 45/69] chore(deps): bump gorm.io/gorm from 1.30.5 to 1.31.0 in the gorm group (#527) Bumps the gorm group with 1 update: [gorm.io/gorm](https://github.com/go-gorm/gorm). Updates `gorm.io/gorm` from 1.30.5 to 1.31.0 - [Release notes](https://github.com/go-gorm/gorm/releases) - [Commits](https://github.com/go-gorm/gorm/compare/v1.30.5...v1.31.0) --- updated-dependencies: - dependency-name: gorm.io/gorm dependency-version: 1.31.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gorm ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4de566e..4d29c8a 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( gorm.io/driver/mysql v1.6.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlserver v1.6.1 - gorm.io/gorm v1.30.5 + gorm.io/gorm v1.31.0 ) require ( diff --git a/go.sum b/go.sum index a109219..7d10550 100644 --- a/go.sum +++ b/go.sum @@ -397,8 +397,8 @@ gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXD gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY= gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= -gorm.io/gorm v1.30.5 h1:dvEfYwxL+i+xgCNSGGBT1lDjCzfELK8fHZxL3Ee9X0s= -gorm.io/gorm v1.30.5/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= +gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s= modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= From d945e313b2c331d0a0bf52cee4fcd8fccbd050d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 22:50:10 +0200 Subject: [PATCH 46/69] chore(deps): bump github.com/go-webauthn/webauthn from 0.13.4 to 0.14.0 (#526) Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.13.4 to 0.14.0. - [Release notes](https://github.com/go-webauthn/webauthn/releases) - [Commits](https://github.com/go-webauthn/webauthn/compare/v0.13.4...v0.14.0) --- updated-dependencies: - dependency-name: github.com/go-webauthn/webauthn dependency-version: 0.14.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 4d29c8a..d4baacd 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-pkgz/routegroup v1.5.3 github.com/go-playground/validator/v10 v10.27.0 - github.com/go-webauthn/webauthn v0.13.4 + github.com/go-webauthn/webauthn v0.14.0 github.com/google/uuid v1.6.0 github.com/prometheus-community/pro-bing v0.7.0 github.com/prometheus/client_golang v1.23.2 @@ -64,7 +64,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-test/deep v1.1.1 // indirect - github.com/go-webauthn/x v0.1.24 // indirect + github.com/go-webauthn/x v0.1.25 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect diff --git a/go.sum b/go.sum index 7d10550..7387def 100644 --- a/go.sum +++ b/go.sum @@ -106,10 +106,10 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ= -github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI= -github.com/go-webauthn/x v0.1.24 h1:6LaWf2zzWqbyKT8IyQkhje1/1KCGhlEkMz4V1tDnt/A= -github.com/go-webauthn/x v0.1.24/go.mod h1:2o5XKJ+X1AKqYKGgHdKflGnoQFQZ6flJ2IFCBKSbSOw= +github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0= +github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k= +github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88= +github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY= 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= @@ -255,6 +255,8 @@ github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= From 7cd7d13dc79f22e13b147438b73780167aa2e0e2 Mon Sep 17 00:00:00 2001 From: h44z Date: Mon, 15 Sep 2025 22:54:34 +0200 Subject: [PATCH 47/69] fix peer creation if custom public key is set (#523) (#528) --- internal/app/wireguard/wireguard_peers.go | 2 + .../app/wireguard/wireguard_peers_test.go | 194 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 internal/app/wireguard/wireguard_peers_test.go diff --git a/internal/app/wireguard/wireguard_peers.go b/internal/app/wireguard/wireguard_peers.go index 99ccdcf..f3c5364 100644 --- a/internal/app/wireguard/wireguard_peers.go +++ b/internal/app/wireguard/wireguard_peers.go @@ -188,6 +188,8 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee sessionUser := domain.GetUserInfo(ctx) + peer.Identifier = domain.PeerIdentifier(peer.Interface.PublicKey) // ensure that identifier corresponds to the public key + // Enforce peer limit for non-admin users if LimitAdditionalUserPeers is set if m.cfg.Core.SelfProvisioningAllowed && !sessionUser.IsAdmin && m.cfg.Advanced.LimitAdditionalUserPeers > 0 { peers, err := m.db.GetUserPeers(ctx, peer.UserIdentifier) diff --git a/internal/app/wireguard/wireguard_peers_test.go b/internal/app/wireguard/wireguard_peers_test.go new file mode 100644 index 0000000..707d015 --- /dev/null +++ b/internal/app/wireguard/wireguard_peers_test.go @@ -0,0 +1,194 @@ +package wireguard + +import ( + "context" + "testing" + + "github.com/h44z/wg-portal/internal/config" + "github.com/h44z/wg-portal/internal/domain" +) + +// --- Test mocks --- + +type mockBus struct{} + +func (f *mockBus) Publish(topic string, args ...any) {} +func (f *mockBus) Subscribe(topic string, fn interface{}) error { return nil } + +type mockController struct{} + +func (f *mockController) GetId() domain.InterfaceBackend { return "local" } +func (f *mockController) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) { + return nil, nil +} +func (f *mockController) GetInterface(_ context.Context, id domain.InterfaceIdentifier) ( + *domain.PhysicalInterface, + error, +) { + return &domain.PhysicalInterface{Identifier: id}, nil +} +func (f *mockController) GetPeers(_ context.Context, _ domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) { + return nil, nil +} +func (f *mockController) SaveInterface( + _ context.Context, + _ domain.InterfaceIdentifier, + updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error), +) error { + _, _ = updateFunc(&domain.PhysicalInterface{}) + return nil +} +func (f *mockController) DeleteInterface(_ context.Context, _ domain.InterfaceIdentifier) error { + return nil +} +func (f *mockController) SavePeer( + _ context.Context, + _ domain.InterfaceIdentifier, + _ domain.PeerIdentifier, + updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error), +) error { + _, _ = updateFunc(&domain.PhysicalPeer{}) + return nil +} +func (f *mockController) DeletePeer(_ context.Context, _ domain.InterfaceIdentifier, _ domain.PeerIdentifier) error { + return nil +} +func (f *mockController) PingAddresses(_ context.Context, _ string) (*domain.PingerResult, error) { + return nil, nil +} + +type mockDB struct { + savedPeers map[domain.PeerIdentifier]*domain.Peer + iface *domain.Interface +} + +func (f *mockDB) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) { + if f.iface != nil && f.iface.Identifier == id { + return f.iface, nil + } + return &domain.Interface{Identifier: id}, nil +} +func (f *mockDB) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) ( + *domain.Interface, + []domain.Peer, + error, +) { + return f.iface, nil, nil +} +func (f *mockDB) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error) { + return nil, nil +} +func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) { return nil, nil } +func (f *mockDB) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error) { + return nil, nil +} +func (f *mockDB) SaveInterface( + ctx context.Context, + id domain.InterfaceIdentifier, + updateFunc func(in *domain.Interface) (*domain.Interface, error), +) error { + if f.iface == nil { + f.iface = &domain.Interface{Identifier: id} + } + var err error + f.iface, err = updateFunc(f.iface) + return err +} +func (f *mockDB) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error { + return nil +} +func (f *mockDB) GetInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.Peer, error) { + return nil, nil +} +func (f *mockDB) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) { + return nil, nil +} +func (f *mockDB) SavePeer( + ctx context.Context, + id domain.PeerIdentifier, + updateFunc func(in *domain.Peer) (*domain.Peer, error), +) error { + if f.savedPeers == nil { + f.savedPeers = make(map[domain.PeerIdentifier]*domain.Peer) + } + existing := f.savedPeers[id] + if existing == nil { + existing = &domain.Peer{Identifier: id} + } + updated, err := updateFunc(existing) + if err != nil { + return err + } + f.savedPeers[updated.Identifier] = updated + return nil +} +func (f *mockDB) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error { return nil } +func (f *mockDB) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) { + return nil, domain.ErrNotFound +} +func (f *mockDB) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr) ( + map[domain.Cidr][]domain.Cidr, + error, +) { + return map[domain.Cidr][]domain.Cidr{}, nil +} + +// --- Test --- + +func TestCreatePeer_SetsIdentifier_FromPublicKey(t *testing.T) { + // Arrange + cfg := &config.Config{} + cfg.Core.SelfProvisioningAllowed = true + cfg.Core.EditableKeys = true + cfg.Advanced.LimitAdditionalUserPeers = 0 + + bus := &mockBus{} + + // Prepare a controller manager with our mock controller + ctrlMgr := &ControllerManager{ + controllers: map[domain.InterfaceBackend]backendInstance{ + config.LocalBackendName: {Implementation: &mockController{}}, + }, + } + + db := &mockDB{iface: &domain.Interface{Identifier: "wg0", Type: domain.InterfaceTypeServer}} + + m := Manager{ + cfg: cfg, + bus: bus, + db: db, + wg: ctrlMgr, + } + + userId := domain.UserIdentifier("user@example.com") + ctx := domain.SetUserInfo(context.Background(), &domain.ContextUserInfo{Id: userId, IsAdmin: false}) + + pubKey := "TEST_PUBLIC_KEY_ABC123" + + input := &domain.Peer{ + Identifier: "should_be_overwritten", + UserIdentifier: userId, + InterfaceIdentifier: domain.InterfaceIdentifier("wg0"), + Interface: domain.PeerInterfaceConfig{ + KeyPair: domain.KeyPair{PublicKey: pubKey}, + }, + } + + // Act + out, err := m.CreatePeer(ctx, input) + + // Assert + if err != nil { + t.Fatalf("CreatePeer returned error: %v", err) + } + + expectedId := domain.PeerIdentifier(pubKey) + if out.Identifier != expectedId { + t.Fatalf("expected Identifier to be set from public key %q, got %q", expectedId, out.Identifier) + } + + // Ensure the saved peer in DB also has the expected identifier + if db.savedPeers[expectedId] == nil { + t.Fatalf("expected peer with identifier %q to be saved in DB", expectedId) + } +} From afb38b685c0f976a5aba8887c3a69097939af569 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Wed, 17 Sep 2025 22:33:54 +0200 Subject: [PATCH 48/69] improve logging of LDAP login process (#529) --- internal/app/auth/auth.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/app/auth/auth.go b/internal/app/auth/auth.go index c6e5232..07c97e8 100644 --- a/internal/app/auth/auth.go +++ b/internal/app/auth/auth.go @@ -364,6 +364,7 @@ func (a *Authenticator) passwordAuthentication( } ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo) if err != nil { + slog.Error("failed to parse ldap user info", "identifier", identifier, "error", err) continue } @@ -376,10 +377,13 @@ func (a *Authenticator) passwordAuthentication( } if userSource == "" { + slog.Warn("no user source found for user", "identifier", identifier, "ldapProviderCount", a.ldapAuthenticators) return nil, errors.New("user not found") } if userSource == domain.UserSourceLdap && ldapProvider == nil { + slog.Warn("no ldap provider found for user", + "identifier", identifier, "ldapProviderCount", a.ldapAuthenticators) return nil, errors.New("ldap provider not found") } From 80693400be15a351d67210591fa8b26a1cfbaefd Mon Sep 17 00:00:00 2001 From: Osvaldo-Net Date: Sun, 21 Sep 2025 05:44:59 -0500 Subject: [PATCH 49/69] =?UTF-8?q?Soporte=20para=20el=20idioma=20espa=C3=B1?= =?UTF-8?q?ol.=20(#530)=20(#532)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add files via upload * Update index.js * Update App.vue * Update es.json --- frontend/src/App.vue | 2 + frontend/src/lang/index.js | 2 + frontend/src/lang/translations/es.json | 587 +++++++++++++++++++++++++ 3 files changed, 591 insertions(+) create mode 100644 frontend/src/lang/translations/es.json diff --git a/frontend/src/App.vue b/frontend/src/App.vue index cddea54..0f3b0b4 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -63,6 +63,7 @@ const languageFlag = computed(() => { uk: "ua", zh: "cn", ko: "kr", + es: "es", }; return "fi-" + (langMap[lang] || lang); @@ -182,6 +183,7 @@ const userDisplayName = computed(() => { Українська Tiếng Việt 中文 + Español
diff --git a/frontend/src/lang/index.js b/frontend/src/lang/index.js index ba3922e..34d5f7a 100644 --- a/frontend/src/lang/index.js +++ b/frontend/src/lang/index.js @@ -8,6 +8,7 @@ import ru from './translations/ru.json'; import uk from './translations/uk.json'; import vi from './translations/vi.json'; import zh from './translations/zh.json'; +import es from './translations/es.json'; import {createI18n} from "vue-i18n"; @@ -32,6 +33,7 @@ const i18n = createI18n({ "uk": uk, "vi": vi, "zh": zh, + "es": es, } }); diff --git a/frontend/src/lang/translations/es.json b/frontend/src/lang/translations/es.json new file mode 100644 index 0000000..00a9792 --- /dev/null +++ b/frontend/src/lang/translations/es.json @@ -0,0 +1,587 @@ +{ + "languages": { + "es": "Español" + }, + "general": { + "pagination": { + "size": "Numero de elementos", + "all": "Todos (Lento)" + }, + "search": { + "placeholder": "Buscar...", + "button": "Buscar" + }, + "select-all": "Buscar todos", + "yes": "Sí", + "no": "No", + "cancel": "Cancelar", + "close": "Cerrar", + "save": "Guardar", + "delete": "Eliminar" + }, + "login": { + "headline": "Por favor inicie sesión", + "username": { + "label": "Usuario", + "placeholder": "Por favor ingrese su usuario" + }, + "password": { + "label": "Contraseña", + "placeholder": "Por favor ingrese su contraseña" + }, + "button": "Ingresar", + "button-webauthn": "Usar clave de acceso" + }, + "menu": { + "home": "Inicio", + "interfaces": "Interfaces", + "users": "Usuarios", + "lang": "Cambiar idioma", + "profile": "Mi perfil", + "settings": "Configuración", + "audit": "Registro de auditoría", + "login": "Iniciar sesión", + "logout": "Cerrar sesión", + "keygen": "Generador de claves" + }, + "home": { + "headline": "Portal VPN WireGuard®", + "info-headline": "Más información", + "abstract": "WireGuard® es una VPN extremadamente simple pero rápida y moderna que utiliza criptografía de última generación. Su objetivo es ser más rápida, simple, ligera y útil que IPsec, a la vez que evita los enormes problemas que supone. Su objetivo es ofrecer un rendimiento considerablemente superior al de OpenVPN.", + "installation": { + "box-header": "Instalación de WireGuard", + "headline": "Instalación", + "content": "Las instrucciones de instalación del cliente se pueden encontrar en el sitio web oficial de WireGuard.", + "button": "Abrir instrucciones" + }, + "about-wg": { + "box-header": "Acerca de WireGuard", + "headline": "Acerca de", + "content": "WireGuard® es una VPN extremadamente simple pero rápida y moderna que utiliza criptografía de última generación.", + "button": "Más" + }, + "about-portal": { + "box-header": "Acerca del Portal WireGuard", + "headline": "Portal WireGuard", + "content": "WireGuard Portal es un portal web simple para la configuración de WireGuard.", + "button": "Más" + }, + "profiles": { + "headline": "Perfiles VPN", + "abstract": "Puedes acceder y descargar tus configuraciones personales de VPN desde tu perfil de usuario.", + "content": "para ver todos tus perfiles configurados, haz clic en el botón de abajo.", + "button": "Abrir mi perfil" + }, + "admin": { + "headline": "Área de administración", + "abstract": "En el área de administración puedes gestionar los peers de WireGuard, la interfaz del servidor, así como los usuarios que tienen acceso al Portal WireGuard.", + "content": "", + "button-admin": "Abrir administración del servidor", + "button-user": "Abrir administración de usuarios" + } + }, + "interfaces": { + "headline": "Administración de interfaces", + "headline-peers": "Peers VPN actuales", + "headline-endpoints": "Extremos actuales", + "no-interface": { + "default-selection": "No hay interfaces disponibles", + "headline": "No se encontraron interfaces...", + "abstract": "Haz clic en el botón + para crear una nueva interfaz WireGuard." + }, + "no-peer": { + "headline": "No hay peers disponibles", + "abstract": "Actualmente no hay peers disponibles para la interfaz WireGuard seleccionada." + }, + "table-heading": { + "name": "Nombre", + "user": "Usuario", + "ip": "IP's", + "endpoint": "Endpoint", + "status": "Estado" + }, + "interface": { + "headline": "Estado de la interfaz para", + "backend": "Backend", + "unknown-backend": "Desconocido", + "wrong-backend": "Backend inválido, usando backend local de WireGuard en su lugar.", + "key": "Clave pública", + "endpoint": "Endpoint público", + "port": "Puerto de escucha", + "peers": "Peers habilitados", + "total-peers": "Peers totales", + "endpoints": "Endpoints habilitados", + "total-endpoints": "Endpoints totales", + "ip": "Dirección IP", + "default-allowed-ip": "IPs permitidas por defecto", + "dns": "Servidores DNS", + "mtu": "MTU", + "default-keep-alive": "Intervalo Keepalive por defecto", + "button-show-config": "Mostrar configuración", + "button-download-config": "Descargar configuración", + "button-store-config": "Guardar configuración para wg-quick", + "button-edit": "Editar interfaz" + }, + "button-add-interface": "Agregar interfaz", + "button-add-peer": "Agregar peer", + "button-add-peers": "Agregar múltiples peers", + "button-show-peer": "Mostrar peer", + "button-edit-peer": "Editar peer", + "peer-disabled": "Peer deshabilitado, motivo:", + "peer-expiring": "El peer expira en", + "peer-connected": "Conectado", + "peer-not-connected": "No conectado", + "peer-handshake": "Último handshake:" + }, + "users": { + "headline": "Administración de usuarios", + "table-heading": { + "id": "ID", + "email": "Correo electrónico", + "firstname": "Nombre", + "lastname": "Apellido", + "source": "Origen", + "peers": "Peers", + "admin": "Administrador" + }, + "no-user": { + "headline": "No hay usuarios disponibles", + "abstract": "Actualmente no hay usuarios registrados en el Portal WireGuard." + }, + "button-add-user": "Agregar usuario", + "button-show-user": "Mostrar usuario", + "button-edit-user": "Editar usuario", + "user-disabled": "Usuario deshabilitado, motivo:", + "user-locked": "Cuenta bloqueada, motivo:", + "admin": "El usuario tiene privilegios de administrador", + "no-admin": "El usuario no tiene privilegios de administrador" + }, + "profile": { + "headline": "Mis peers VPN", + "table-heading": { + "name": "Nombre", + "ip": "IP's", + "stats": "Estado", + "interface": "Interfaz del servidor" + }, + "no-peer": { + "headline": "No hay peers disponibles", + "abstract": "Actualmente no hay peers asociados a tu perfil de usuario." + }, + "peer-connected": "Conectado", + "button-add-peer": "Agregar peer", + "button-show-peer": "Mostrar peer", + "button-edit-peer": "Editar peer" + }, + "settings": { + "headline": "Configuración", + "abstract": "Aquí puedes cambiar tu configuración personal.", + "api": { + "headline": "Configuración de API", + "abstract": "Aquí puedes configurar los ajustes de la API RESTful.", + "active-description": "La API está actualmente activa para tu cuenta. Todas las solicitudes están autenticadas con Basic Auth. Usa las siguientes credenciales.", + "inactive-description": "La API está actualmente inactiva. Presiona el botón de abajo para activarla.", + "user-label": "Usuario de la API:", + "user-placeholder": "Usuario de la API", + "token-label": "Contraseña de la API:", + "token-placeholder": "Token de la API", + "token-created-label": "Acceso API concedido en: ", + "button-disable-title": "Desactivar API, invalidará el token actual.", + "button-disable-text": "Desactivar API", + "button-enable-title": "Activar API, generará un nuevo token.", + "button-enable-text": "Activar API", + "api-link": "Documentación de API" + }, + "webauthn": { + "headline": "Configuración de llave de acceso", + "abstract": "Las llaves de acceso son una forma moderna de autenticar usuarios sin necesidad de contraseñas. Se almacenan de forma segura en tu navegador y pueden usarse para iniciar sesión en el Portal WireGuard.", + "active-description": "Al menos una llave de acceso está activa en tu cuenta.", + "inactive-description": "Actualmente no hay llaves de acceso registradas. Presiona el botón de abajo para registrar una.", + "table": { + "name": "Nombre", + "created": "Creada", + "actions": "" + }, + "credentials-list": "Llaves de acceso registradas actualmente", + "modal-delete": { + "headline": "Eliminar llaves de acceso", + "abstract": "¿Seguro que deseas eliminar esta llave de acceso? Ya no podrás usarla para iniciar sesión.", + "created": "Creada:", + "button-delete": "Eliminar", + "button-cancel": "Cancelar" + }, + "button-rename-title": "Renombrar", + "button-rename-text": "Renombrar la llave de acceso.", + "button-save-title": "Guardar", + "button-save-text": "Guardar el nuevo nombre de la llave de acceso.", + "button-cancel-title": "Cancelar", + "button-cancel-text": "Cancelar el renombrado de la llave de acceso.", + "button-delete-title": "Eliminar", + "button-delete-text": "Eliminar la llave de acceso. Ya no podrás iniciar sesión con ella.", + "button-register-title": "Registrar llave de acceso", + "button-register-text": "Registrar una nueva llave de acceso para proteger tu cuenta." + } + }, + "audit": { + "headline": "Registro de Auditoría", + "abstract": "Aquí puedes encontrar el registro de auditoría de todas las acciones realizadas en el Portal WireGuard.", + "no-entries": { + "headline": "No hay entradas en el registro", + "abstract": "Actualmente no se han registrado auditorías." + }, + "entries-headline": "Entradas del Registro", + "table-heading": { + "id": "#", + "time": "Hora", + "user": "Usuario", + "severity": "Severidad", + "origin": "Origen", + "message": "Mensaje" + } + }, + "keygen": { + "headline": "Generador de claves WireGuard", + "abstract": "Genera nuevas claves de WireGuard. Las claves se generan en tu navegador local y nunca se envían al servidor.", + "headline-keypair": "Nuevo par de claves", + "headline-preshared-key": "Nueva clave pre-compartida", + "button-generate": "Generar", + "private-key": { + "label": "Clave privada", + "placeholder": "La clave privada" + }, + "public-key": { + "label": "Clave pública", + "placeholder": "La clave pública" + }, + "preshared-key": { + "label": "Clave pre-compartida", + "placeholder": "La clave pre-compartida" + } + }, + "modals": { + "user-view": { + "headline": "Cuenta de Usuario:", + "tab-user": "Información", + "tab-peers": "Peers", + "headline-info": "Información del Usuario:", + "headline-notes": "Notas:", + "email": "Correo Electrónico", + "firstname": "Nombre", + "lastname": "Apellido", + "phone": "Número de Teléfono", + "depeertment": "Departamento", + "api-enabled": "Acceso API", + "disabled": "Cuenta Deshabilitada", + "locked": "Cuenta Bloqueada", + "no-peers": "El usuario no tiene peers asociados.", + "peers": { + "name": "Nombre", + "interface": "Interfaz", + "ip": "IP's" + } + }, + "user-edit": { + "headline-edit": "Editar usuario:", + "headline-new": "Nuevo usuario", + "header-general": "General", + "header-personal": "Información del Usuario", + "header-notes": "Notas", + "header-state": "Estado", + "identifier": { + "label": "Identificador", + "placeholder": "El identificador único del usuario" + }, + "source": { + "label": "Origen", + "placeholder": "El origen del usuario" + }, + "password": { + "label": "Contraseña", + "placeholder": "Una contraseña súper segura", + "description": "Deja este campo en blanco para mantener la contraseña actual.", + "too-weak": "La contraseña es demasiado débil. Por favor usa una más fuerte." + }, + "email": { + "label": "Correo", + "placeholder": "La dirección de correo" + }, + "phone": { + "label": "Teléfono", + "placeholder": "El número de teléfono" + }, + "depeertment": { + "label": "Departamento", + "placeholder": "El departamento" + }, + "firstname": { + "label": "Nombre", + "placeholder": "Nombre" + }, + "lastname": { + "label": "Apellido", + "placeholder": "Apellido" + }, + "notes": { + "label": "Notas", + "placeholder": "" + }, + "disabled": { + "label": "Deshabilitado (sin conexión WireGuard y sin posibilidad de inicio de sesión)" + }, + "locked": { + "label": "Bloqueado (no es posible iniciar sesión, las conexiones WireGuard aún funcionan)" + }, + "admin": { + "label": "Es administrador" + } + }, + "interface-view": { + "headline": "Configuración de la interfaz:" + }, + "interface-edit": { + "headline-edit": "Editar interfaz:", + "headline-new": "Nueva interfaz", + "tab-interface": "Interfaz", + "tab-peerdef": "Valores predeterminados del peer", + "header-general": "General", + "header-network": "Red", + "header-crypto": "Criptografía", + "header-hooks": "Hooks de interfaz", + "header-peer-hooks": "Hooks", + "header-state": "Estado", + "identifier": { + "label": "Identificador", + "placeholder": "El identificador único de la interfaz" + }, + "mode": { + "label": "Modo de Interfaz", + "server": "Modo Servidor", + "client": "Modo Cliente", + "any": "Modo Desconocido" + }, + "backend": { + "label": "Backend de la Interfaz", + "invalid-label": "El backend original ya no está disponible, usando el backend local de WireGuard en su lugar.", + "local": "Backend local de WireGuard" + }, + "display-name": { + "label": "Nombre para Mostrar", + "placeholder": "El nombre descriptivo de la interfaz" + }, + "private-key": { + "label": "La clave Privada", + "placeholder": "La clave privada" + }, + "public-key": { + "label": "La clave pública", + "placeholder": "La clave pública" + }, + "ip": { + "label": "Direcciones IP", + "placeholder": "Direcciones IP (formato CIDR)" + }, + "listen-port": { + "label": "Puerto de Escucha", + "placeholder": "El puerto de escucha" + }, + "dns": { + "label": "Servidor DNS", + "placeholder": "Los servidores DNS que deben usarse" + }, + "dns-search": { + "label": "Dominios de Búsqueda DNS", + "placeholder": "Prefijos de búsqueda DNS" + }, + "mtu": { + "label": "MTU", + "placeholder": "La MTU de la interfaz (0 = mantener por defecto)" + }, + "firewall-mark": { + "label": "Marca de Firewall", + "placeholder": "Marca de firewall que se aplica al tráfico saliente. (0 = automático)" + }, + "routing-table": { + "label": "Tabla de Enrutamiento", + "placeholder": "El ID de la tabla de enrutamiento", + "description": "Casos especiales: off = no administrar rutas, 0 = automático" + }, + "pre-up": { + "label": "Pre-Up", + "placeholder": "Uno o varios comandos bash separados por ;" + }, + "post-up": { + "label": "Post-Up", + "placeholder": "Uno o varios comandos bash separados por ;" + }, + "pre-down": { + "label": "Pre-Down", + "placeholder": "Uno o varios comandos bash separados por ;" + }, + "post-down": { + "label": "Post-Down", + "placeholder": "Uno o varios comandos bash separados por ;" + }, + "disabled": { + "label": "Interfaz Deshabilitada" + }, + "save-config": { + "label": "Guardar automáticamente la configuración de wg-quick" + }, + "defaults": { + "endpoint": { + "label": "Dirección del Endpoint", + "placeholder": "Dirección del Endpoint", + "description": "La dirección del endpoint al que los peers se conectarán. (ej: wg.ejemplo.com o wg.ejemplo.com:51820)" + }, + "networks": { + "label": "Redes IP", + "placeholder": "Direcciones de Red", + "description": "Los peers obtendrán direcciones IP de esas subredes." + }, + "allowed-ip": { + "label": "Direcciones IP Permitidas", + "placeholder": "Direcciones IP Permitidas por Defecto" + }, + "mtu": { + "label": "MTU", + "placeholder": "La MTU del cliente (0 = mantener por defecto)" + }, + "keep-alive": { + "label": "Intervalo de Keep Alive", + "placeholder": "Keepalive Persistente (0 = por defecto)" + } + }, + "button-apply-defaults": "Aplicar Valores Predeterminados de peers" + }, + "peer-view": { + "headline-peer": "Peer:", + "headline-endpoint": "Endpoint:", + "section-info": "Información del peer", + "section-status": "Estado Actual", + "section-config": "Configuración", + "identifier": "Identificador", + "ip": "Direcciones IP", + "user": "Usuario Asociado", + "notes": "Notas", + "expiry-status": "Expira en", + "disabled-status": "Deshabilitado en", + "traffic": "Tráfico", + "connection-status": "Estadísticas de Conexión", + "upload": "Bytes Subidos (del Servidor al peer)", + "download": "Bytes Descargados (del peer al Servidor)", + "pingable": "Es Alcanzable (Ping)", + "handshake": "Último Handshake", + "connected-since": "Conectado desde", + "endpoint": "Endpoint", + "button-download": "Descargar configuración", + "button-email": "Enviar configuración por Correo Electrónico", + "style-label": "Estilo de Configuración" + }, + "peer-edit": { + "headline-edit-peer": "Editar peer:", + "headline-edit-endpoint": "Editar endpoint:", + "headline-new-peer": "Crear peer", + "headline-new-endpoint": "Crear endpoint", + "header-general": "General", + "header-network": "Red", + "header-crypto": "Criptografía", + "header-hooks": "Hooks (Ejecutados en el peer)", + "header-state": "Estado", + "display-name": { + "label": "Nombre para Mostrar", + "placeholder": "El nombre descriptivo para el peer" + }, + "linked-user": { + "label": "Usuario Vinculado", + "placeholder": "La cuenta de usuario que posee este peer" + }, + "private-key": { + "label": "Clave Privada", + "placeholder": "Clave privada", + "help": "La clave privada se almacena de forma segura en el servidor. Si el usuario ya posee una copia, puedes omitir este campo. El servidor sigue funcionando exclusivamente con la clave pública del peer." + }, + "public-key": { + "label": "Cave Pública", + "placeholder": "La Clave pública" + }, + "preshared-key": { + "label": "Clave pre-compartida", + "placeholder": "Clave pre-compartida opcional" + }, + "endpoint": { + "label": "Dirección del endpoint", + "placeholder": "La dirección del endpoint remoto" + }, + "ip": { + "label": "Direcciones IP", + "placeholder": "Direcciones IP (formato CIDR)" + }, + "allowed-ip": { + "label": "Direcciones IP permitidas", + "placeholder": "Direcciones IP permitidas (formato CIDR)" + }, + "extra-allowed-ip": { + "label": "Direcciones IP permitidas extra", + "placeholder": "IPs extra permitidas (lado del servidor)", + "description": "Esas IPs serán agregadas en la interfaz remota de WireGuard como direcciones IP permitidas." + }, + "dns": { + "label": "Servidor DNS", + "placeholder": "Los servidores DNS que deben usarse" + }, + "dns-search": { + "label": "Dominios de búsqueda DNS", + "placeholder": "Prefijos de búsqueda DNS" + }, + "keep-alive": { + "label": "Intervalo de Keep Alive", + "placeholder": "Keepalive Persistente (0 = por defecto)" + }, + "mtu": { + "label": "MTU", + "placeholder": "La MTU del cliente (0 = mantener por defecto)" + }, + "pre-up": { + "label": "Pre-Up", + "placeholder": "Uno o varios comandos bash separados por ;" + }, + "post-up": { + "label": "Post-Up", + "placeholder": "Uno o varios comandos bash separados por ;" + }, + "pre-down": { + "label": "Pre-Down", + "placeholder": "Uno o varios comandos bash separados por ;" + }, + "post-down": { + "label": "Post-Down", + "placeholder": "Uno o varios comandos bash separados por ;" + }, + "disabled": { + "label": "Peer Deshabilitado" + }, + "ignore-global": { + "label": "Ignorar configuración global" + }, + "expires-at": { + "label": "Fecha de expiración" + } + }, + "peer-multi-create": { + "headline-peer": "Crear múltiples peers", + "headline-endpoint": "Crear múltiples endpoints", + "identifiers": { + "label": "Identificadores de Usuario", + "placeholder": "Identificadores de Usuario", + "description": "Un identificador de usuario (el nombre de usuario) para el cual debe crearse un peer." + }, + "prefix": { + "headline-peer": "peer:", + "headline-endpoint": "Endpoint:", + "label": "Prefijo del Nombre peera Mostrar", + "placeholder": "El prefijo", + "description": "Un prefijo que se agregará al nombre mostrado de los peers." + } + } + } +} From 61bf349813a6989bc073c50736fb5b51012b65d4 Mon Sep 17 00:00:00 2001 From: h44z Date: Sun, 21 Sep 2025 13:02:12 +0200 Subject: [PATCH 50/69] add user's display-name to peer view (#525) (#534) --- frontend/src/helpers/models.js | 1 + frontend/src/views/InterfaceView.vue | 2 +- internal/app/api/v0/model/models_peer.go | 9 +++++- internal/domain/peer.go | 26 ++++++++++++++++++ internal/domain/user.go | 35 +++++++++++++++--------- 5 files changed, 58 insertions(+), 15 deletions(-) diff --git a/frontend/src/helpers/models.js b/frontend/src/helpers/models.js index 6e1e52b..5c0d1d7 100644 --- a/frontend/src/helpers/models.js +++ b/frontend/src/helpers/models.js @@ -53,6 +53,7 @@ export function freshPeer() { Identifier: "", DisplayName: "", UserIdentifier: "", + UserDisplayName: "", InterfaceIdentifier: "", Disabled: false, ExpiresAt: null, diff --git a/frontend/src/views/InterfaceView.vue b/frontend/src/views/InterfaceView.vue index b28cf5f..ecd253e 100644 --- a/frontend/src/views/InterfaceView.vue +++ b/frontend/src/views/InterfaceView.vue @@ -400,7 +400,7 @@ onMounted(async () => { {{peer.DisplayName}}{{ $filters.truncate(peer.Identifier, 10)}} - {{peer.UserIdentifier}} + {{peer.UserIdentifier}} {{ ip }} diff --git a/internal/app/api/v0/model/models_peer.go b/internal/app/api/v0/model/models_peer.go index b0e2f7e..c7843c5 100644 --- a/internal/app/api/v0/model/models_peer.go +++ b/internal/app/api/v0/model/models_peer.go @@ -43,6 +43,7 @@ type Peer struct { Identifier string `json:"Identifier" example:"super_nice_peer"` // peer unique identifier DisplayName string `json:"DisplayName"` // a nice display name/ description for the peer UserIdentifier string `json:"UserIdentifier"` // the owner + UserDisplayName string `json:"UserDisplayName"` // the owner display name InterfaceIdentifier string `json:"InterfaceIdentifier"` // the interface id Disabled bool `json:"Disabled"` // flag that specifies if the peer is enabled (up) or not (down) DisabledReason string `json:"DisabledReason"` // the reason why the peer has been disabled @@ -80,7 +81,7 @@ type Peer struct { } func NewPeer(src *domain.Peer) *Peer { - return &Peer{ + p := &Peer{ Identifier: string(src.Identifier), DisplayName: src.DisplayName, UserIdentifier: string(src.UserIdentifier), @@ -111,6 +112,12 @@ func NewPeer(src *domain.Peer) *Peer { PostDown: ConfigOptionFromDomain(src.Interface.PostDown), Filename: src.GetConfigFileName(), } + + if src.User != nil { + p.UserDisplayName = src.User.DisplayName() + } + + return p } func NewPeers(src []domain.Peer) []Peer { diff --git a/internal/domain/peer.go b/internal/domain/peer.go index c47f63e..2ea3254 100644 --- a/internal/domain/peer.go +++ b/internal/domain/peer.go @@ -1,12 +1,14 @@ package domain import ( + "errors" "fmt" "net" "strings" "time" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "gorm.io/gorm" "github.com/h44z/wg-portal/internal" "github.com/h44z/wg-portal/internal/config" @@ -44,6 +46,7 @@ type Peer struct { DisplayName string // a nice display name/ description for the peer Identifier PeerIdentifier `gorm:"primaryKey;column:identifier"` // peer unique identifier UserIdentifier UserIdentifier `gorm:"index;column:user_identifier"` // the owner + User *User `gorm:"-"` // the owner user object; loaded automatically after fetch InterfaceIdentifier InterfaceIdentifier `gorm:"index;column:interface_identifier"` // the interface id Disabled *time.Time `gorm:"column:disabled"` // if this field is set, the peer is disabled DisabledReason string // the reason why the peer has been disabled @@ -348,3 +351,26 @@ type PeerCreationRequest struct { UserIdentifiers []string Prefix string } + +// AfterFind is a GORM hook that automatically loads the associated User object +// based on the UserIdentifier field. If the identifier is empty or no user is +// found, the User field is set to nil. +func (p *Peer) AfterFind(tx *gorm.DB) error { + if p == nil { + return nil + } + if p.UserIdentifier == "" { + p.User = nil + return nil + } + var u User + if err := tx.Where("identifier = ?", p.UserIdentifier).First(&u).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + p.User = nil + return nil + } + return err + } + p.User = &u + return nil +} diff --git a/internal/domain/user.go b/internal/domain/user.go index 84b5345..ee5f371 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -185,6 +185,27 @@ func (u *User) CopyCalculatedAttributes(src *User) { u.LinkedPeerCount = src.LinkedPeerCount } +// DisplayName returns the display name of the user. +// The display name is the first and last name, or the email address of the user. +// If none of these fields are set, the user identifier is returned. +func (u *User) DisplayName() string { + var displayName string + switch { + case u.Firstname != "" && u.Lastname != "": + displayName = fmt.Sprintf("%s %s", u.Firstname, u.Lastname) + case u.Firstname != "": + displayName = u.Firstname + case u.Lastname != "": + displayName = u.Lastname + case u.Email != "": + displayName = u.Email + default: + displayName = string(u.Identifier) + } + + return displayName +} + // region webauthn func (u *User) WebAuthnID() []byte { @@ -209,19 +230,7 @@ func (u *User) WebAuthnName() string { } func (u *User) WebAuthnDisplayName() string { - var userName string - switch { - case u.Firstname != "" && u.Lastname != "": - userName = fmt.Sprintf("%s %s", u.Firstname, u.Lastname) - case u.Firstname != "": - userName = u.Firstname - case u.Lastname != "": - userName = u.Lastname - default: - userName = string(u.Identifier) - } - - return userName + return u.DisplayName() } func (u *User) WebAuthnCredentials() []webauthn.Credential { From ea26e56994318222248832de123bfd74f225d743 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Sun, 21 Sep 2025 21:16:12 +0200 Subject: [PATCH 51/69] fix delayed setup of external auth providers (#529) --- internal/app/auth/auth.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/app/auth/auth.go b/internal/app/auth/auth.go index 07c97e8..10ec1b1 100644 --- a/internal/app/auth/auth.go +++ b/internal/app/auth/auth.go @@ -125,11 +125,27 @@ func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserM // It sets up the external authentication providers (OIDC, OAuth, LDAP) and retries in case of errors. func (a *Authenticator) StartBackgroundJobs(ctx context.Context) { go func() { + slog.Debug("setting up external auth providers...") + // Initialize local copies of authentication providers to allow retry in case of errors oidcQueue := a.cfg.OpenIDConnect oauthQueue := a.cfg.OAuth ldapQueue := a.cfg.Ldap + // Immediate attempt + failedOidc, failedOauth, failedLdap := a.setupExternalAuthProviders(oidcQueue, oauthQueue, ldapQueue) + if len(failedOidc) == 0 && len(failedOauth) == 0 && len(failedLdap) == 0 { + slog.Info("successfully setup all external auth providers") + return + } + + // Prepare for retries with only the failed ones + oidcQueue = failedOidc + oauthQueue = failedOauth + ldapQueue = failedLdap + slog.Warn("failed to setup some external auth providers, retrying in 30 seconds", + "failedOidc", len(failedOidc), "failedOauth", len(failedOauth), "failedLdap", len(failedLdap)) + ticker := time.NewTicker(30 * time.Second) // Ticker for delay between retries defer ticker.Stop() From b122e1ae60147aa8d56cbbf989e35b3bcd710e6d Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Mon, 22 Sep 2025 18:45:41 +0200 Subject: [PATCH 52/69] add tzdata to docker image (#531) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b264f97..cee602a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,7 +52,7 @@ COPY --from=builder /build/dist/wg-portal / ###### FROM alpine:3.22 # Install OS-level dependencies -RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools +RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools tzdata # Setup timezone ENV TZ=UTC # Copy binaries From cc2d1f53c44671dda30bb863644dd0957fe558e7 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Wed, 24 Sep 2025 18:39:45 +0200 Subject: [PATCH 53/69] improve logging of LDAP login process even more (#529) --- internal/app/auth/auth.go | 11 +++++++---- internal/app/auth/auth_ldap.go | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/app/auth/auth.go b/internal/app/auth/auth.go index 10ec1b1..1ad1718 100644 --- a/internal/app/auth/auth.go +++ b/internal/app/auth/auth.go @@ -374,13 +374,15 @@ func (a *Authenticator) passwordAuthentication( rawUserInfo, err := ldapAuth.GetUserInfo(context.Background(), identifier) if err != nil { if !errors.Is(err, domain.ErrNotFound) { - slog.Warn("failed to fetch ldap user info", "identifier", identifier, "error", err) + slog.Warn("failed to fetch ldap user info", + "source", ldapAuth.GetName(), "identifier", identifier, "error", err) } continue // user not found / other ldap error } ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo) if err != nil { - slog.Error("failed to parse ldap user info", "identifier", identifier, "error", err) + slog.Error("failed to parse ldap user info", + "source", ldapAuth.GetName(), "identifier", identifier, "error", err) continue } @@ -393,13 +395,14 @@ func (a *Authenticator) passwordAuthentication( } if userSource == "" { - slog.Warn("no user source found for user", "identifier", identifier, "ldapProviderCount", a.ldapAuthenticators) + slog.Warn("no user source found for user", + "identifier", identifier, "ldapProviderCount", len(a.ldapAuthenticators), "inDb", userInDatabase) return nil, errors.New("user not found") } if userSource == domain.UserSourceLdap && ldapProvider == nil { slog.Warn("no ldap provider found for user", - "identifier", identifier, "ldapProviderCount", a.ldapAuthenticators) + "identifier", identifier, "ldapProviderCount", len(a.ldapAuthenticators), "inDb", userInDatabase) return nil, errors.New("ldap provider not found") } diff --git a/internal/app/auth/auth_ldap.go b/internal/app/auth/auth_ldap.go index d75c3b6..84bdcd1 100644 --- a/internal/app/auth/auth_ldap.go +++ b/internal/app/auth/auth_ldap.go @@ -113,10 +113,13 @@ func (l LdapAuthenticator) GetUserInfo(_ context.Context, userId domain.UserIden } if len(sr.Entries) == 0 { + slog.Debug("LDAP user not found", "source", l.GetName(), "userId", userId, "filter", loginFilter) return nil, domain.ErrNotFound } if len(sr.Entries) > 1 { + slog.Debug("LDAP user not unique", + "source", l.GetName(), "userId", userId, "filter", loginFilter, "entries", len(sr.Entries)) return nil, domain.ErrNotUnique } From 97b6c398e8859c8d6ccbdecea4879c826c3f3d56 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Fri, 3 Oct 2025 17:30:14 +0200 Subject: [PATCH 54/69] fix incorrect handling of client mode (#537) --- frontend/src/components/PeerEditModal.vue | 2 +- frontend/src/components/PeerViewModal.vue | 32 ++++++++++++++--------- frontend/src/lang/translations/de.json | 4 +++ frontend/src/lang/translations/en.json | 4 +++ internal/domain/peer.go | 31 +++++++++++++++------- 5 files changed, 50 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/PeerEditModal.vue b/frontend/src/components/PeerEditModal.vue index 7c50edf..10d0559 100644 --- a/frontend/src/components/PeerEditModal.vue +++ b/frontend/src/components/PeerEditModal.vue @@ -358,7 +358,7 @@ async function del() {
-
+

%9j{!;CDrpvWZ5)4+$k~&WE{vYp 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 literal 0 HcmV?d00001 diff --git a/docs/assets/images/passkey_setup.png b/docs/assets/images/passkey_setup.png new file mode 100644 index 0000000000000000000000000000000000000000..af39e89f10ec1afbeec13d577d02761f1f3bc158 GIT binary patch literal 108988 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 literal 0 HcmV?d00001 diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 158d060..70496aa 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -552,6 +552,8 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`: ```text (&(objectClass=organizationalPerson)(mail={{login_identifier}})(!userAccountControl:1.2.840.113556.1.4.803:=2)) ``` +- **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. #### `admin_group` - **Default:** *(empty)* diff --git a/docs/documentation/usage/general.md b/docs/documentation/usage/general.md new file mode 100644 index 0000000..6ebf5be --- /dev/null +++ b/docs/documentation/usage/general.md @@ -0,0 +1,57 @@ +This documentation section describes the general usage of WireGuard Portal. +If you are looking for specific setup instructions, please refer to the *Getting Started* and [*Configuration*](../configuration/overview.md) sections, +for example, using a [Docker](../getting-started/docker.md) deployment. + +## Basic Concepts + +WireGuard Portal is a web-based configuration portal for WireGuard server management. It allows managing multiple WireGuard interfaces and users from a single web UI. +WireGuard Interfaces can be categorized into three types: + + - **Server**: A WireGuard server interface that to which multiple peers can connect. In this mode, it is possible to specify default settings for all peers, such as the IP address range, DNS servers, and MTU size. + - **Client**: A WireGuard client interface that can be used to connect to a WireGuard server. Usually, such an interface has exactly one peer. + - **Unknown**: This is the default type for imported interfaces. It is encouraged to change the type to either `Server` or `Client` after importing the interface. + +## Accessing the Web UI + +The web UI should be accessed via the URL specified in the `external_url` property of the configuration file. +By default, WireGuard Portal listens on port `8888` for HTTP connections. Check the [Security](security.md) section for more information on securing the web UI. + +So the default URL to access the web UI is: + +``` +http://localhost:8888 +``` + +A freshly set-up WireGuard Portal instance will have a default admin user with the username `admin@wgportal.local` and the password `wgportal-default`. +You can and should override the default credentials in the configuration file. Make sure to change the default password immediately after the first login! + + +### Basic UI Description + +![WireGuard Portal Web UI](../../assets/images/landing_page.png) + +As seen in the screenshot above, the web UI is divided into several sections which are accessible via the navigation bar on the top of the screen. + +1. **Home**: The landing page of WireGuard Portal. It provides a staring point for the user to access the different sections of the web UI. It also provides quick links to WireGuard Client downloads or official documentation. +2. **Interfaces**: This section allows you to manage the WireGuard interfaces. You can add, edit, or delete interfaces, as well as view their status and statistics. Peers for each interface can be managed here as well. +3. **Users**: This section allows you to manage the users of WireGuard Portal. You can add, edit, or delete users, as well as view their status and statistics. +4. **Key Generator**: This section allows you to generate WireGuard keys locally on your browser. The generated keys are never sent to the server. This is useful if you want to generate keys for a new peer without having to store the private keys in the database. +5. **Profile / Settings**: This section allows you to access your own profile page, settings, and audit logs. + + +### Interface View + +![WireGuard Portal Interface View](../../assets/images/interface_view.png) + +The interface view provides an overview of the WireGuard interfaces and peers configured in WireGuard Portal. + +The most important elements are: + +1. **Interface Selector**: This dropdown allows you to select the WireGuard interface you want to manage. + All further actions will be performed on the selected interface. +2. **Create new Interface**: This button allows you to create a new WireGuard interface. +3. **Interface Overview**: This section provides an overview of the selected WireGuard interface. It shows the interface type, number of peers, and other important information. +4. **List of Peers**: This section provides a list of all peers associated with the selected WireGuard interface. You can view, add, edit, or delete peers from this list. +5. **Add new Peer**: This button allows you to add a new peer to the selected WireGuard interface. +6. **Add multiple Peers**: This button allows you to add multiple peers to the selected WireGuard interface. + This is useful if you want to add a large number of peers at once. \ No newline at end of file diff --git a/docs/documentation/usage/ldap.md b/docs/documentation/usage/ldap.md new file mode 100644 index 0000000..d0ebc77 --- /dev/null +++ b/docs/documentation/usage/ldap.md @@ -0,0 +1,37 @@ +WireGuard Portal lets you hook up any LDAP server such as Active Directory or OpenLDAP for both authentication and user sync. +You can even register multiple LDAP servers side-by-side. When someone logs in via LDAP, their specific provider is remembered, +so there's no risk of cross-provider conflicts. Details on the log-in process can be found in the [Security](security.md#ldap-authentication) documentation. + +If you enable LDAP synchronization, all users within the LDAP directory will be created automatically in the WireGuard Portal database if they do not exist. +If a user is disabled or deleted in LDAP, the user will be disabled in WireGuard Portal as well. +The synchronization process can be fine-tuned by multiple parameters, which are described below. + +## LDAP Synchronization + +WireGuard Portal can automatically synchronize users from LDAP to the database. +To enable this feature, set the `sync_interval` property in the LDAP provider configuration to a value greater than "0". +The value is a string representing a duration, such as "15m" for 15 minutes or "1h" for 1 hour (check the [exact format definition](https://pkg.go.dev/time#ParseDuration) for details). +The synchronization process will run in the background and synchronize users from LDAP to the database at the specified interval. +Also make sure that the `sync_filter` property is a well-formed LDAP filter, or synchronization will fail. + +### Limiting Synchronization to Specific Users + +Use the `sync_filter` property in your LDAP provider block to restrict which users get synchronized. +It accepts any valid LDAP search filter, only entries matching that filter will be pulled into the portal's database. + +For example, to import only users with a `mail` attribute: +```yaml +auth: + ldap: + - id: ldap + # ... other settings + sync_filter: (mail=*) +``` + +### Disable Missing Users + +If you set the `disable_missing` property to `true`, any user that is not found in LDAP during synchronization will be disabled in WireGuard Portal. +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 where disabled by the synchronization process. Manually disabled users will not be re-enabled. \ No newline at end of file diff --git a/docs/documentation/usage/security.md b/docs/documentation/usage/security.md new file mode 100644 index 0000000..41f36aa --- /dev/null +++ b/docs/documentation/usage/security.md @@ -0,0 +1,160 @@ +This section describes the security features available to administrators for hardening WireGuard Portal and protecting its data. + +## Authentication + +WireGuard Portal supports multiple authentication methods, including: + +- Local user accounts +- LDAP authentication +- OAuth and OIDC authentication +- Passkey authentication (WebAuthn) + +Users can have two roles which limit their permissions in WireGuard Portal: + +- **User**: Can manage their own account and peers. +- **Admin**: Can manage all users and peers, including the ability to manage WireGuard interfaces. + +### Password Security + +WireGuard Portal supports username and password authentication for both local and LDAP-backed accounts. +Local users are stored in the database, while LDAP users are authenticated against an external LDAP server. + +On initial startup, WireGuard Portal automatically creates a local admin account with the password `wgportal-default`. +> :warning: This password must be changed immediately after the first login. + +The minimum password length for all local users can be configured in the [`auth`](../configuration/overview.md#auth) +section of the configuration file. The default value is **16** characters, see [`min_password_length`](../configuration/overview.md#min_password_length). +The minimum password length is also enforced for the default admin user. + + +### Passkey (WebAuthn) Authentication + +Besides the standard authentication mechanisms, WireGuard Portal supports Passkey authentication. +This feature is enabled by default and can be configured in the [`webauthn`](../configuration/overview.md#webauthn-passkeys) section of the configuration file. + +Users can register multiple Passkeys to their account. These Passkeys can be used to log in to the web UI as long as the user is not locked. +> :warning: Passkey authentication does not disable password authentication. The password can still be used to log in (e.g., as a fallback). + +To register a Passkey, open the settings page *(1)* in the web UI and click on the "Register Passkey" *(2)* button. + +![Passkey UI](../../assets/images/passkey_setup.png) + + +### OAuth and OIDC Authentication + +WireGuard Portal supports OAuth and OIDC authentication. You can use any OAuth or OIDC provider that supports the authorization code flow, +such as Google, GitHub, or Keycloak. + +For OAuth or OIDC to work, you need to configure the [`external_url`](../configuration/overview.md#external_url) property in the [`web`](../configuration/overview.md#web) section of the configuration file. +If you are planning to expose the portal to the internet, make sure that the `external_url` is configured to use HTTPS. + +To add OIDC or OAuth authentication to WireGuard Portal, create a Client-ID and Client-Secret in your OAuth provider and +configure a new authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file. +Make sure that each configured provider has a unique `provider_name` property set. Samples can be seen [here](../configuration/examples.md). + +#### Limiting Login to Specific Domains + +You can limit the login to specific domains by setting the `allowed_domains` property for OAuth or OIDC providers. +This property is a comma-separated list of domains that are allowed to log in. The user's email address is checked against this list. +For example, if you want to allow only users with an email address ending in `outlook.com` to log in, set the property as follows: + +```yaml +auth: + oidc: + - provider_name: "oidc1" + # ... other settings + allowed_domains: + - "outlook.com" +``` + +#### Limit Login to Existing Users + +You can limit the login to existing users only by setting the `registration_enabled` property to `false` for OAuth or OIDC providers. +If registration is enabled, new users will be created in the database when they log in for the first time. + +#### Admin Mapping + +You can map users to admin roles based on their attributes in the OAuth or OIDC provider. To do this, set the `admin_mapping` property for the provider. +Administrative access can either be mapped by a specific attribute or by group membership. + +**Attribute specific mapping** can be achieved by setting the `admin_value_regex` and the `is_admin` property. +The `admin_value_regex` property is a regular expression that is matched against the value of the `is_admin` attribute. +The user is granted admin access if the regex matches the attribute value. + +Example: +```yaml +auth: + oidc: + - provider_name: "oidc1" + # ... other settings + field_map: + is_admin: "wg_admin_prop" + admin_mapping: + admin_value_regex: "^true$" +``` +The example above will grant admin access to users with the `wg_admin_prop` attribute set to `true`. + +**Group membership mapping** can be achieved by setting the `admin_group_regex` and `user_groups` property. +The `admin_group_regex` property is a regular expression that is matched against the group names of the user. +The user is granted admin access if the regex matches any of the group names. + +Example: +```yaml +auth: + oidc: + - provider_name: "oidc1" + # ... other settings + field_map: + user_groups: "groups" + admin_mapping: + admin_group_regex: "^the-admin-group$" +``` +The example above will grant admin access to users who are members of the `the-admin-group` group. + + +### LDAP Authentication + +WireGuard Portal supports LDAP authentication. You can use any LDAP server that supports the LDAP protocol, such as Active Directory or OpenLDAP. +Multiple LDAP servers can be configured in the [`auth`](../configuration/overview.md#auth) section of the configuration file. +WireGuard Portal remembers the authentication provider of the user and therefore avoids conflicts between multiple LDAP providers. + +To configure LDAP authentication, create a new [`ldap`](../configuration/overview.md#ldap) authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file. + +#### Limiting Login to Specific Users + +You can limit the login to specific users by setting the `login_filter` property for LDAP provider. This filter uses the LDAP search filter syntax. +The username can be inserted into the query by placing the `{{login_identifier}}` placeholder in the filter. This placeholder will then be replaced with the username entered by the user during login. + +For example, if you want to allow only users with the `objectClass` attribute set to `organizationalPerson` to log in, set the property as follows: + +```yaml +auth: + ldap: + - provider_name: "ldap1" + # ... other settings + login_filter: "(&(objectClass=organizationalPerson)(uid={{login_identifier}}))" +``` + +The `login_filter` should always be designed to return at most one user. + +#### Limit Login to Existing Users + +You can limit the login to existing users only by setting the `registration_enabled` property to `false` for LDAP providers. +If registration is enabled, new users will be created in the database when they log in for the first time. + +#### Admin Mapping + +You can map users to admin roles based on their group membership in the LDAP server. To do this, set the `admin_group` and `memberof` property for the provider. +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. + + +## UI and API Access + +WireGuard Portal provides a web UI and a REST API for user interaction. It is important to secure these interfaces to prevent unauthorized access and data breaches. + +### HTTPS +It is recommended to use HTTPS for all communication with the portal to prevent eavesdropping. + +Event though, WireGuard Portal supports HTTPS out of the box, it is recommended to use a reverse proxy like Nginx or Traefik to handle SSL termination and other security features. +A detailed explanation is available in the [Reverse Proxy](../getting-started/reverse-proxy.md) section. \ No newline at end of file diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index a771004..0291ca7 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -65,7 +65,7 @@ const auth = authStore()