Compare commits

...

18 Commits

Author SHA1 Message Date
dependabot[bot] da76327569
chore(deps): bump golang.org/x/oauth2 from 0.31.0 to 0.32.0 (#544)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.31.0 to 0.32.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.31.0...v0.32.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-15 21:14:22 +02:00
dependabot[bot] c154cb3977
chore(deps): bump golang.org/x/sys from 0.36.0 to 0.37.0 (#545)
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.36.0 to 0.37.0.
- [Commits](https://github.com/golang/sys/compare/v0.36.0...v0.37.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-15 21:12:15 +02:00
dependabot[bot] 7bca35728d
chore(deps): bump golang.org/x/crypto from 0.42.0 to 0.43.0 (#546)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.42.0 to 0.43.0.
- [Commits](https://github.com/golang/crypto/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-15 21:12:04 +02:00
h44z 3d923b328e
password change UI (#543) (#548) 2025-10-15 21:11:40 +02:00
Christoph Haas 139fb17f98
redo UI screenshots, fix the responsiveness of the image slider for wgportal.org 2025-10-12 15:48:08 +02:00
Christoph Haas faf1d995a8
fix parsing IP addresses in UI (ip-address lib was updated to V10) 2025-10-12 15:20:38 +02:00
Christoph Haas f53d0b3d7f
add the possibility to debug oauth or oidc login issues (#541) 2025-10-12 15:09:40 +02:00
h44z cdf3a49801
Cleanup route handling (#542)
* mikrotik: allow to set DNS, wip: handle routes in wg-controller

* replace old route handling for local controller

* cleanup route handling for local backend

* implement route handling for mikrotik controller
2025-10-12 14:31:19 +02:00
Christoph Haas 298c9405f6
add support for sending emails to peers without linked user accounts if their user-identifier is a valid email address 2025-10-12 14:31:01 +02:00
Christoph c7724b620a smaller UI improvements; add system theme 2025-10-12 00:48:35 +02:00
Christoph Haas 4d19f1d8bb
fix a small visual glitch in dialogs 2025-10-06 21:10:39 +02:00
Christoph Haas 3f539a1615
improve interface view: show correct DNS entries 2025-10-06 19:26:51 +02:00
PROLICHT GmbH 0305911467
Merge pull request #539 from h44z/dependabot/go_modules/github.com/go-playground/validator/v10-10.28.0
chore(deps): bump github.com/go-playground/validator/v10 from 10.27.0 to 10.28.0
2025-10-06 16:05:21 +02:00
dependabot[bot] 85f7a5a9a6
chore(deps): bump github.com/go-playground/validator/v10
Bumps [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) from 10.27.0 to 10.28.0.
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.27.0...v10.28.0)

---
updated-dependencies:
- dependency-name: github.com/go-playground/validator/v10
  dependency-version: 10.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-06 13:48:44 +00:00
Christoph Haas fb509a39b8
Merge remote-tracking branch 'origin/master' 2025-10-04 14:24:45 +02:00
h44z 9e6ad98c4e
Doc improvements (#538)
* add dark/light image to doc

* add dark/light image to doc

* add funding info, prepare release v2.1
2025-10-04 14:17:29 +02:00
Christoph Haas 05fbcccc9c
chore: update dependencies 2025-10-04 13:22:51 +02:00
Christoph Haas 97b6c398e8
fix incorrect handling of client mode (#537) 2025-10-03 17:30:14 +02:00
57 changed files with 3096 additions and 1699 deletions

3
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,3 @@
# These are supported funding model platforms
github: h44z # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]

View File

@ -1,4 +1,4 @@
Copyright (c) 2020-2023 Christoph Haas Copyright (c) 2020-2025 Christoph Haas
Permission is hereby granted, free of charge, to any person obtaining Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the a copy of this software and associated documentation files (the

View File

@ -21,7 +21,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos
## Features ## Features
* Self-hosted - the whole application is a single binary * Self-hosted - the whole application is a single binary
* Responsive multi-language web UI written in Vue.js * Responsive multi-language web UI with dark-mode written in Vue.js
* Automatically selects IP from the network pool assigned to the client * Automatically selects IP from the network pool assigned to the client
* QR-Code for convenient mobile client configuration * QR-Code for convenient mobile client configuration
* Sends email to the client with QR-code and client config * Sends email to the client with QR-code and client config
@ -32,7 +32,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos
* Docker ready * Docker ready
* Can be used with existing WireGuard setups * Can be used with existing WireGuard setups
* Support for multiple WireGuard interfaces * Support for multiple WireGuard interfaces
* Supports multiple WireGuard backends (wgctrl or MikroTik [BETA]) * Supports multiple WireGuard backends (wgctrl or MikroTik)
* Peer Expiry Feature * Peer Expiry Feature
* Handles route and DNS settings like wg-quick does * Handles route and DNS settings like wg-quick does
* Exposes Prometheus metrics for monitoring and alerting * Exposes Prometheus metrics for monitoring and alerting
@ -62,6 +62,17 @@ For the complete documentation visit [wgportal.org](https://wgportal.org).
* MIT License. [MIT](LICENSE.txt) or <https://opensource.org/licenses/MIT> * MIT License. [MIT](LICENSE.txt) or <https://opensource.org/licenses/MIT>
## Contributors and Sponsors
Thanks so much for all your contributions! Theyre truly appreciated and help keep WireGuard Portal moving ahead.
<a href="https://github.com/h44z/wg-portal/graphs/contributors">
<img src="https://contrib.rocks/image?repo=h44z/wg-portal" />
</a>
Want to support the project? You can buy me a coffee or join as a contributor - every bit of support helps!
[Become a sponsor!](https://github.com/sponsors/h44z)
> [!IMPORTANT] > [!IMPORTANT]
> Since the project was accepted by the Docker-Sponsored Open Source Program, the Docker image location has moved to [wgportal/wg-portal](https://hub.docker.com/r/wgportal/wg-portal). > Since the project was accepted by the Docker-Sponsored Open Source Program, the Docker image location has moved to [wgportal/wg-portal](https://hub.docker.com/r/wgportal/wg-portal).

View File

@ -7,7 +7,7 @@ If you believe you've found a security issue in one of the supported versions of
| Version | Supported | | Version | Supported |
|---------|--------------------| |---------|--------------------|
| v2.x | :white_check_mark: | | v2.x | :white_check_mark: |
| v1.x | :white_check_mark: | | v1.x | :x: |
## Reporting a Vulnerability ## Reporting a Vulnerability

View File

@ -53,8 +53,6 @@ func main() {
wireGuard, err := wireguard.NewControllerManager(cfg) wireGuard, err := wireguard.NewControllerManager(cfg)
internal.AssertNoError(err) internal.AssertNoError(err)
wgQuick := adapters.NewWgQuickRepo()
mailer := adapters.NewSmtpMailRepo(cfg.Mail) mailer := adapters.NewSmtpMailRepo(cfg.Mail)
metricsServer := adapters.NewMetricsServer(cfg) metricsServer := adapters.NewMetricsServer(cfg)
@ -93,7 +91,7 @@ func main() {
webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager) webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager)
internal.AssertNoError(err) internal.AssertNoError(err)
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database) wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, database)
internal.AssertNoError(err) internal.AssertNoError(err)
wireGuardManager.StartBackgroundJobs(ctx) wireGuardManager.StartBackgroundJobs(ctx)
@ -107,7 +105,7 @@ func main() {
mailManager, err := mail.NewMailManager(cfg, mailer, cfgFileManager, database, database) mailManager, err := mail.NewMailManager(cfg, mailer, cfgFileManager, database, database)
internal.AssertNoError(err) internal.AssertNoError(err)
routeManager, err := route.NewRouteManager(cfg, eventBus, database) routeManager, err := route.NewRouteManager(cfg, eventBus, database, wireGuard)
internal.AssertNoError(err) internal.AssertNoError(err)
routeManager.StartBackgroundJobs(ctx) routeManager.StartBackgroundJobs(ctx)

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

View File

@ -15,6 +15,9 @@ backend:
# default backend decides where new interfaces are created # default backend decides where new interfaces are created
default: mikrotik default: mikrotik
# A prefix for resolvconf. Usually it is "tun.". If you are using systemd, the prefix should be empty.
local_resolvconf_prefix: "tun."
mikrotik: mikrotik:
- id: mikrotik # unique id, not "local" - id: mikrotik # unique id, not "local"
display_name: RouterOS RB5009 # optional nice name display_name: RouterOS RB5009 # optional nice name

View File

@ -28,6 +28,7 @@ core:
backend: backend:
default: local default: local
local_resolvconf_prefix: tun.
advanced: advanced:
log_level: info log_level: info
@ -72,6 +73,7 @@ mail:
auth_type: plain auth_type: plain
from: Wireguard Portal <noreply@wireguard.local> from: Wireguard Portal <noreply@wireguard.local>
link_only: false link_only: false
allow_peer_email: false
auth: auth:
oidc: [] oidc: []
@ -184,6 +186,11 @@ The current MikroTik backend is in **BETA** and may not support all features.
- **Description:** The default backend to use for managing WireGuard interfaces. - **Description:** The default backend to use for managing WireGuard interfaces.
Valid options are: `local`, or other backend id's configured in the `mikrotik` section. Valid options are: `local`, or other backend id's configured in the `mikrotik` section.
### `local_resolvconf_prefix`
- **Default:** `tun.`
- **Description:** Interface name prefix for WireGuard interfaces on the local system which is used to configure DNS servers with *resolvconf*.
It depends on the *resolvconf* implementation you are using, most use a prefix of `tun.`, but some have an empty prefix (e.g., systemd).
### `ignored_local_interfaces` ### `ignored_local_interfaces`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A list of interface names to exclude when enumerating local interfaces. - **Description:** A list of interface names to exclude when enumerating local interfaces.
@ -381,6 +388,8 @@ Controls how WireGuard Portal collects and reports usage statistics, including p
## Mail ## Mail
Options for configuring email notifications or sending peer configurations via email. Options for configuring email notifications or sending peer configurations via email.
By default, emails will only be sent to peers that have a valid user record linked.
To send emails to all peers that have a valid email-address as user-identifier, set `allow_peer_email` to `true`.
### `host` ### `host`
- **Default:** `127.0.0.1` - **Default:** `127.0.0.1`
@ -418,6 +427,12 @@ Options for configuring email notifications or sending peer configurations via e
- **Default:** `false` - **Default:** `false`
- **Description:** If `true`, emails only contain a link to WireGuard Portal, rather than attaching the full configuration. - **Description:** If `true`, emails only contain a link to WireGuard Portal, rather than attaching the full configuration.
### `allow_peer_email`
- **Default:** `false`
- **Description:** If `true`, and a peer has no valid user record linked, but the user-identifier of the peer is a valid email address, emails will be sent to that email address.
If false, and the peer has no valid user record linked, emails will not be sent.
If a peer has linked a valid user, the email address is always taken from the user record.
--- ---
## Auth ## Auth
@ -497,13 +512,18 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex. - `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
#### `registration_enabled` #### `registration_enabled`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, a new user will be created in WireGuard Portal if not already present. - **Description:** If `true`, a new user will be created in WireGuard Portal if not already present.
#### `log_user_info` #### `log_user_info`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, OIDC user data is logged at the trace level upon login (for debugging). - **Description:** If `true`, OIDC user data is logged at the trace level upon login (for debugging).
#### `log_sensitive_info`
- **Default:** `false`
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
--- ---
### OAuth ### OAuth
@ -570,13 +590,18 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex. - `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
#### `registration_enabled` #### `registration_enabled`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, new users are created automatically on successful login. - **Description:** If `true`, new users are created automatically on successful login.
#### `log_user_info` #### `log_user_info`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, logs user info at the trace level upon login. - **Description:** If `true`, logs user info at the trace level upon login.
#### `log_sensitive_info`
- **Default:** `false`
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
--- ---
### LDAP ### LDAP
@ -593,11 +618,11 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
- **Description:** The LDAP server URL (e.g., `ldap://srv-ad01.company.local:389`). - **Description:** The LDAP server URL (e.g., `ldap://srv-ad01.company.local:389`).
#### `start_tls` #### `start_tls`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, use STARTTLS to secure the LDAP connection. - **Description:** If `true`, use STARTTLS to secure the LDAP connection.
#### `cert_validation` #### `cert_validation`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, validate the LDAP servers TLS certificate. - **Description:** If `true`, validate the LDAP servers TLS certificate.
#### `tls_certificate_path` #### `tls_certificate_path`
@ -667,19 +692,19 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
``` ```
#### `disable_missing` #### `disable_missing`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal. - **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal.
#### `auto_re_enable` #### `auto_re_enable`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, users that where disabled because they were missing (see `disable_missing`) will be re-enabled once they are found again. - **Description:** If `true`, users that where disabled because they were missing (see `disable_missing`) will be re-enabled once they are found again.
#### `registration_enabled` #### `registration_enabled`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login. - **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login.
#### `log_user_info` #### `log_user_info`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, logs LDAP user data at the trace level upon login. - **Description:** If `true`, logs LDAP user data at the trace level upon login.
--- ---

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,15 @@
img-comparison-slider {
visibility: hidden;
}
img-comparison-slider [slot='second'] {
display: none;
}
img-comparison-slider.rendered {
visibility: inherit;
}
img-comparison-slider.rendered [slot='second'] {
display: unset;
}

View File

@ -68,7 +68,7 @@
} }
.tx-hero__image { .tx-hero__image {
max-width: 1000px; max-width: 1000px;
min-width: 600px; min-width: 0;
width: 100%; width: 100%;
height: auto; height: auto;
margin: 0 auto; margin: 0 auto;
@ -218,7 +218,7 @@
.secondary-section .g .section .component-wrapper .responsive-grid .card { .secondary-section .g .section .component-wrapper .responsive-grid .card {
position: relative; position: relative;
background-color: #fff none repeat scroll 0% 0%; background-color: #fff;
padding: 1.5rem; padding: 1.5rem;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -300,6 +300,59 @@
background: var(--md-accent-fg-color--transparent); background: var(--md-accent-fg-color--transparent);
} }
.before,
.after {
margin: 0;
}
.after figcaption {
background: #fff;
font-weight: bold;
border: 1px solid #c0c0c0;
color: #000000;
opacity: 0.9;
padding: 9px;
position: absolute;
top: 100%;
transform: translateY(-100%);
line-height: 100%;
}
.before figcaption {
background: #000;
font-weight: bold;
border: 1px solid #c0c0c0;
color: #ffffff;
opacity: 0.9;
padding: 9px;
position: absolute;
top: 100%;
transform: translateY(-100%);
line-height: 100%;
}
.before figcaption {
left: 0px;
}
.after figcaption {
right: 0px;
}
.custom-animated-handle {
transition: transform 0.2s;
}
.slider-with-animated-handle:hover .custom-animated-handle {
transform: scale(1.2);
}
.md-typeset img-comparison-slider figure {
margin: initial;
}
.first-overlay {
color: #000;
}
</style> </style>
<!-- Hero for landing page --> <!-- Hero for landing page -->
@ -310,7 +363,6 @@
<h1>A beautiful and simple UI to manage your WireGuard peers and interfaces</h1> <h1>A beautiful and simple UI to manage your WireGuard peers and interfaces</h1>
<p>WireGuard Portal is an open source web-based user interface that makes it easy to setup and manage <p>WireGuard Portal is an open source web-based user interface that makes it easy to setup and manage
WireGuard VPN connections. It's built on top of WireGuard's official <span class="em">wgctrl</span> library.</p> WireGuard VPN connections. It's built on top of WireGuard's official <span class="em">wgctrl</span> library.</p>
</p>
<a <a
href="documentation/overview/" href="documentation/overview/"
title="Get Started" title="Get Started"
@ -326,11 +378,34 @@
<div class="md-container"> <div class="md-container">
<div class="tx-hero__image"> <div class="tx-hero__image">
<img <div>
src="{{config.site_url}}/assets/images/screenshot.png" <img-comparison-slider hover="hover">
alt="" <figure slot="first" class="before">
draggable="false" <img src="{{config.site_url}}/assets/images/wgportal_light.png" alt="Light Mode"/>
> <figcaption>Light Mode</figcaption>
</figure>
<figure slot="second" class="after">
<img src="{{config.site_url}}/assets/images/wgportal_dark.png" alt="Dark Mode"/>
<figcaption>Dark Mode</figcaption>
</figure>
<svg slot="handle" class="custom-animated-handle" xmlns="http://www.w3.org/2000/svg" width="100" viewBox="-8 -3 16 6">
<!-- Left arrow (dark) -->
<path d="M -5 -2 L -7 0 L -5 2 M -5 -2 L -5 2"
stroke="#1a1a1a"
fill="#1a1a1a"
stroke-width="1"
vector-effect="non-scaling-stroke">
</path>
<!-- Right arrow (white) -->
<path d="M 5 -2 L 7 0 L 5 2 M 5 -2 L 5 2"
stroke="#fff"
fill="#fff"
stroke-width="1"
vector-effect="non-scaling-stroke">
</path>
</svg>
</img-comparison-slider>
</div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,6 @@
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"sass-embedded": "^1.86.3", "sass-embedded": "^1.86.3",
"vite": "6.3.4" "vite": "^6.3.6"
} }
} }

View File

@ -1,6 +1,6 @@
<script setup> <script setup>
import { RouterLink, RouterView } from 'vue-router'; import { RouterLink, RouterView } from 'vue-router';
import { computed, getCurrentInstance, onMounted, ref } from "vue"; import {computed, getCurrentInstance, nextTick, onMounted, ref} from "vue";
import { authStore } from "./stores/auth"; import { authStore } from "./stores/auth";
import { securityStore } from "./stores/security"; import { securityStore } from "./stores/security";
import { settingsStore } from "@/stores/settings"; import { settingsStore } from "@/stores/settings";
@ -11,12 +11,13 @@ const auth = authStore()
const sec = securityStore() const sec = securityStore()
const settings = settingsStore() const settings = settingsStore()
const currentTheme = ref("auto")
onMounted(async () => { onMounted(async () => {
console.log("Starting WireGuard Portal frontend..."); console.log("Starting WireGuard Portal frontend...");
// restore theme from localStorage // restore theme from localStorage
const theme = localStorage.getItem('wgTheme') || 'light'; switchTheme(getTheme());
document.documentElement.setAttribute('data-bs-theme', theme);
await sec.LoadSecurityProperties(); await sec.LoadSecurityProperties();
await auth.LoadProviders(); await auth.LoadProviders();
@ -44,10 +45,22 @@ const switchLanguage = function (lang) {
} }
} }
const getTheme = function () {
return localStorage.getItem('wgTheme') || 'auto';
}
const switchTheme = function (theme) { const switchTheme = function (theme) {
if (document.documentElement.getAttribute('data-bs-theme') !== theme) { let bsTheme = theme;
if (theme === 'auto') {
bsTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
currentTheme.value = theme;
if (document.documentElement.getAttribute('data-bs-theme') !== bsTheme) {
console.log("Switching theme to " + theme + " (" + bsTheme + ")");
localStorage.setItem('wgTheme', theme); localStorage.setItem('wgTheme', theme);
document.documentElement.setAttribute('data-bs-theme', theme); document.documentElement.setAttribute('data-bs-theme', bsTheme);
} }
} }
@ -137,20 +150,25 @@ const userDisplayName = computed(() => {
<div v-if="!auth.IsAuthenticated" class="nav-item"> <div v-if="!auth.IsAuthenticated" class="nav-item">
<RouterLink :to="{ name: 'login' }" class="nav-link"><i class="fas fa-sign-in-alt fa-sm fa-fw me-2"></i>{{ $t('menu.login') }}</RouterLink> <RouterLink :to="{ name: 'login' }" class="nav-link"><i class="fas fa-sign-in-alt fa-sm fa-fw me-2"></i>{{ $t('menu.login') }}</RouterLink>
</div> </div>
<div class="nav-item dropdown" data-bs-theme="light"> <div class="nav-item dropdown" :key="currentTheme">
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="theme-menu" aria-expanded="false" data-bs-toggle="dropdown" data-bs-display="static" aria-label="Toggle theme"> <a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="theme-menu" aria-expanded="false" data-bs-toggle="dropdown" data-bs-display="static" aria-label="Toggle theme">
<i class="fa-solid fa-circle-half-stroke"></i> <i class="fa-solid fa-circle-half-stroke"></i>
<span class="d-lg-none ms-2">Toggle theme</span> <span class="d-lg-none ms-2">Toggle theme</span>
</a> </a>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li>
<button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('auto')" aria-pressed="false">
<i class="fa-solid fa-circle-half-stroke"></i><span class="ms-2">System</span><i class="fa-solid fa-check ms-5" :class="{invisible:currentTheme!=='auto'}"></i>
</button>
</li>
<li> <li>
<button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('light')" aria-pressed="false"> <button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('light')" aria-pressed="false">
<i class="fa-solid fa-sun"></i><span class="ms-2">Light</span> <i class="fa-solid fa-sun"></i><span class="ms-2">Light</span><i class="fa-solid fa-check ms-5" :class="{invisible:currentTheme!=='light'}"></i>
</button> </button>
</li> </li>
<li> <li>
<button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('dark')" aria-pressed="true"> <button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('dark')" aria-pressed="true">
<i class="fa-solid fa-moon"></i><span class="ms-2">Dark</span> <i class="fa-solid fa-moon"></i><span class="ms-2">Dark</span><i class="fa-solid fa-check ms-5" :class="{invisible:currentTheme!=='dark'}"></i>
</button> </button>
</li> </li>
</ul> </ul>
@ -221,4 +239,8 @@ const userDisplayName = computed(() => {
background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important; background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important;
color: var(--bs-badge-color)!important; color: var(--bs-badge-color)!important;
} }
[data-bs-theme=dark] .navbar-dark, .navbar {
background-color: #000 !important;
}
</style> </style>

View File

@ -105,3 +105,7 @@ a.disabled {
.vue-tags-input .ti-deletion-mark:after { .vue-tags-input .ti-deletion-mark:after {
transform: scaleX(1); transform: scaleX(1);
} }
.modal-dialog {
box-shadow: none;
}

View File

@ -316,6 +316,16 @@ async function del() {
isDeleting.value = true isDeleting.value = true
try { try {
await interfaces.DeleteInterface(selectedInterface.value.Identifier) await interfaces.DeleteInterface(selectedInterface.value.Identifier)
// reload all interfaces and peers
await interfaces.LoadInterfaces()
if (interfaces.Count > 0 && interfaces.GetSelected !== undefined) {
const selectedInterface = interfaces.GetSelected
await peers.LoadPeers(selectedInterface.Identifier)
await peers.LoadStats(selectedInterface.Identifier)
} else {
await peers.Reset() // reset peers if no interfaces are available
}
close() close()
} catch (e) { } catch (e) {
console.log(e) console.log(e)
@ -434,6 +444,11 @@ async function del() {
<label class="form-label mt-4">{{ $t('modals.interface-edit.firewall-mark.label') }}</label> <label class="form-label mt-4">{{ $t('modals.interface-edit.firewall-mark.label') }}</label>
<input v-model="formData.FirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number"> <input v-model="formData.FirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number">
</div> </div>
<div class="form-group col-md-6" v-if="formData.Backend!=='local'">
<label class="form-label mt-4">{{ $t('modals.interface-edit.routing-table.label') }}</label>
<input v-model="formData.RoutingTable" aria-describedby="routingTableHelp" class="form-control" :placeholder="$t('modals.interface-edit.routing-table.placeholder')" type="text">
<small id="routingTableHelp" class="form-text text-muted">{{ $t('modals.interface-edit.routing-table.description') }}</small>
</div>
<div class="form-group col-md-6" v-else> <div class="form-group col-md-6" v-else>
</div> </div>
</div> </div>
@ -447,7 +462,7 @@ async function del() {
</div> </div>
</div> </div>
</fieldset> </fieldset>
<fieldset> <fieldset v-if="formData.Backend==='local'">
<legend class="mt-4">{{ $t('modals.interface-edit.header-hooks') }}</legend> <legend class="mt-4">{{ $t('modals.interface-edit.header-hooks') }}</legend>
<div class="form-group"> <div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.pre-up.label') }}</label> <label class="form-label mt-4">{{ $t('modals.interface-edit.pre-up.label') }}</label>
@ -472,7 +487,7 @@ async function del() {
<input v-model="formData.Disabled" class="form-check-input" type="checkbox"> <input v-model="formData.Disabled" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label> <label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label>
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch" v-if="formData.Backend==='local'">
<input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox"> <input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label> <label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label>
</div> </div>

View File

@ -358,7 +358,7 @@ async function del() {
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint.placeholder')" <input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint.placeholder')"
v-model="formData.Endpoint.Value"> v-model="formData.Endpoint.Value">
</div> </div>
<div class="form-group"> <div class="form-group" v-if="selectedInterface.Mode !== 'client'">
<label class="form-label mt-4">{{ $t('modals.peer-edit.ip.label') }}</label> <label class="form-label mt-4">{{ $t('modals.peer-edit.ip.label') }}</label>
<vue-tags-input class="form-control" v-model="currentTags.Addresses" <vue-tags-input class="form-control" v-model="currentTags.Addresses"
:tags="formData.Addresses.map(str => ({ text: str }))" :tags="formData.Addresses.map(str => ({ text: str }))"

View File

@ -130,7 +130,7 @@ function ConfigQrUrl() {
<template> <template>
<Modal :title="title" :visible="visible" @close="close"> <Modal :title="title" :visible="visible" @close="close">
<template #default> <template #default>
<div class="d-flex justify-content-end align-items-center mb-1"> <div class="d-flex justify-content-end align-items-center mb-1" v-if="selectedInterface.Mode !== 'client'">
<span class="me-2">{{ $t('modals.peer-view.style-label') }}: </span> <span class="me-2">{{ $t('modals.peer-view.style-label') }}: </span>
<div class="btn-group btn-switch-group" role="group" aria-label="Configuration Style"> <div class="btn-group btn-switch-group" role="group" aria-label="Configuration Style">
<input type="radio" class="btn-check" name="configstyle" id="raw" value="raw" autocomplete="off" checked="" v-model="configStyle"> <input type="radio" class="btn-check" name="configstyle" id="raw" value="raw" autocomplete="off" checked="" v-model="configStyle">
@ -151,20 +151,28 @@ function ConfigQrUrl() {
data-bs-parent="#peerInformation" style=""> data-bs-parent="#peerInformation" style="">
<div class="accordion-body"> <div class="accordion-body">
<div class="row"> <div class="row">
<div class="col-md-8"> <div :class="{ 'col-md-8': selectedInterface.Mode !== 'client', 'col-md-12': selectedInterface.Mode !== 'server' }" class="col-md-8">
<ul> <ul>
<li>{{ $t('modals.peer-view.identifier') }}: {{ selectedPeer.PublicKey }}</li> <li v-if="selectedInterface.Mode !== 'client'"><strong>{{ $t('modals.peer-view.identifier') }}</strong>: {{ selectedPeer.PublicKey }}</li>
<li>{{ $t('modals.peer-view.ip') }}: <span v-for="ip in selectedPeer.Addresses" :key="ip" <li v-if="selectedInterface.Mode !== 'server'"><strong>{{ $t('modals.peer-view.endpoint-key') }}</strong>: {{ selectedPeer.PublicKey }}</li>
<li v-if="selectedInterface.Mode !== 'server'"><strong>{{ $t('modals.peer-view.endpoint') }}</strong>: {{ selectedPeer.Endpoint.Value }}</li>
<li v-if="selectedInterface.Mode !== 'client'"><strong>{{ $t('modals.peer-view.ip') }}</strong>: <span v-for="ip in selectedPeer.Addresses" :key="ip"
class="badge rounded-pill bg-light">{{ ip }}</span></li> class="badge rounded-pill bg-light">{{ ip }}</span></li>
<li>{{ $t('modals.peer-view.user') }}: {{ selectedPeer.UserIdentifier }}</li> <li v-if="selectedInterface.Mode === 'server'"><strong>{{ $t('modals.peer-view.extra-allowed-ip') }}</strong>: <span v-for="ip in selectedPeer.ExtraAllowedIPs" :key="ip"
<li v-if="selectedPeer.Notes">{{ $t('modals.peer-view.notes') }}: {{ selectedPeer.Notes }}</li> class="badge rounded-pill bg-light">{{ ip }}</span></li>
<li v-if="selectedPeer.ExpiresAt">{{ $t('modals.peer-view.expiry-status') }}: {{ <li v-if="selectedInterface.Mode !== 'server' && selectedPeer.AllowedIPs.Value"><strong>{{ $t('modals.peer-view.allowed-ip') }}</strong>: <span v-for="ip in selectedPeer.AllowedIPs.Value" :key="ip"
class="badge rounded-pill bg-light">{{ ip }}</span></li>
<li v-if="selectedInterface.Mode !== 'server'"><strong>{{ $t('modals.peer-view.keepalive') }}</strong>: {{ selectedPeer.PersistentKeepalive.Value }}</li>
<li v-if="selectedPeer.UserDisplayName"><strong>{{ $t('modals.peer-view.user') }}</strong>: {{ selectedPeer.UserDisplayName }} ({{ selectedPeer.UserIdentifier }})</li>
<li v-else><strong>{{ $t('modals.peer-view.user') }}</strong>: {{ selectedPeer.UserIdentifier }}</li>
<li v-if="selectedPeer.Notes"><strong>{{ $t('modals.peer-view.notes') }}</strong>: {{ selectedPeer.Notes }}</li>
<li v-if="selectedPeer.ExpiresAt"><strong>{{ $t('modals.peer-view.expiry-status') }}</strong>: {{
selectedPeer.ExpiresAt }}</li> selectedPeer.ExpiresAt }}</li>
<li v-if="selectedPeer.Disabled">{{ $t('modals.peer-view.disabled-status') }}: {{ <li v-if="selectedPeer.Disabled"><strong>{{ $t('modals.peer-view.disabled-status') }}</strong>: {{
selectedPeer.DisabledReason }}</li> selectedPeer.DisabledReason }}</li>
</ul> </ul>
</div> </div>
<div class="col-md-4"> <div class="col-md-4" v-if="selectedInterface.Mode !== 'client'">
<img class="config-qr-img" :src="ConfigQrUrl()" loading="lazy" alt="Configuration QR Code"> <img class="config-qr-img" :src="ConfigQrUrl()" loading="lazy" alt="Configuration QR Code">
</div> </div>
</div> </div>
@ -199,7 +207,7 @@ function ConfigQrUrl() {
</div> </div>
</div> </div>
</div> </div>
<div v-if="selectedInterface.Mode === 'server'" class="accordion-item"> <div v-if="selectedInterface.Mode !== 'client'" class="accordion-item">
<h2 class="accordion-header" id="headingConfig"> <h2 class="accordion-header" id="headingConfig">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseConfig" aria-expanded="false" aria-controls="collapseConfig"> data-bs-target="#collapseConfig" aria-expanded="false" aria-controls="collapseConfig">
@ -217,9 +225,9 @@ function ConfigQrUrl() {
</template> </template>
<template #footer> <template #footer>
<div class="flex-fill text-start"> <div class="flex-fill text-start">
<button @click.prevent="download" type="button" class="btn btn-primary me-1">{{ <button v-if="selectedInterface.Mode !== 'client'" @click.prevent="download" type="button" class="btn btn-primary me-1">{{
$t('modals.peer-view.button-download') }}</button> $t('modals.peer-view.button-download') }}</button>
<button @click.prevent="email" type="button" class="btn btn-primary me-1">{{ <button v-if="selectedInterface.Mode !== 'client'" @click.prevent="email" type="button" class="btn btn-primary me-1">{{
$t('modals.peer-view.button-email') }}</button> $t('modals.peer-view.button-email') }}</button>
</div> </div>
<button @click.prevent="close" type="button" class="btn btn-secondary">{{ $t('general.close') }}</button> <button @click.prevent="close" type="button" class="btn btn-secondary">{{ $t('general.close') }}</button>

View File

@ -4,12 +4,12 @@ export function ipToBigInt(ip) {
// Check if it's an IPv4 address // Check if it's an IPv4 address
if (ip.includes(".")) { if (ip.includes(".")) {
const addr = new Address4(ip) const addr = new Address4(ip)
return addr.bigInteger() return addr.bigInt()
} }
// Otherwise, assume it's an IPv6 address // Otherwise, assume it's an IPv6 address
const addr = new Address6(ip) const addr = new Address6(ip)
return addr.bigInteger() return addr.bigInt()
} }
export function humanFileSize(size) { export function humanFileSize(size) {

View File

@ -117,6 +117,7 @@
"dns": "DNS-Server", "dns": "DNS-Server",
"mtu": "MTU", "mtu": "MTU",
"default-keep-alive": "Standard Keepalive-Intervall", "default-keep-alive": "Standard Keepalive-Intervall",
"default-dns": "Standard DNS-Server",
"button-show-config": "Konfiguration anzeigen", "button-show-config": "Konfiguration anzeigen",
"button-download-config": "Konfiguration herunterladen", "button-download-config": "Konfiguration herunterladen",
"button-store-config": "Konfiguration für wg-quick speichern", "button-store-config": "Konfiguration für wg-quick speichern",
@ -220,6 +221,16 @@
"button-delete-text": "Passkey löschen. Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.", "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-title": "Passkey registrieren",
"button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern." "button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern."
},
"password": {
"headline": "Passwort-Einstellungen",
"abstract": "Hier können Sie Ihr Passwort ändern.",
"current-label": "Aktuelles Passwort",
"new-label": "Neues Passwort",
"new-confirm-label": "Neues Passwort bestätigen",
"change-button-text": "Passwort ändern",
"invalid-confirm-label": "Passwörter stimmen nicht überein",
"weak-label": "Passwort ist zu schwach"
} }
}, },
"audit": { "audit": {
@ -461,6 +472,8 @@
"section-config": "Konfiguration", "section-config": "Konfiguration",
"identifier": "Kennung", "identifier": "Kennung",
"ip": "IP-Adressen", "ip": "IP-Adressen",
"allowed-ip": "Erlaubte IP-Adressen",
"extra-allowed-ip": "Serverseitig erlaubte IP-Adressen",
"user": "Zugeordneter Benutzer", "user": "Zugeordneter Benutzer",
"notes": "Notizen", "notes": "Notizen",
"expiry-status": "Läuft ab am", "expiry-status": "Läuft ab am",
@ -473,6 +486,8 @@
"handshake": "Letzter Handshake", "handshake": "Letzter Handshake",
"connected-since": "Verbunden seit", "connected-since": "Verbunden seit",
"endpoint": "Endpunkt", "endpoint": "Endpunkt",
"endpoint-key": "Öffentlicher Endpunkt-Schlüssel",
"keepalive": "Persistentes Keepalive",
"button-download": "Konfiguration herunterladen", "button-download": "Konfiguration herunterladen",
"button-email": "Konfiguration per E-Mail senden", "button-email": "Konfiguration per E-Mail senden",
"style-label": "Konfigurationsformat" "style-label": "Konfigurationsformat"

View File

@ -117,6 +117,7 @@
"dns": "DNS Servers", "dns": "DNS Servers",
"mtu": "MTU", "mtu": "MTU",
"default-keep-alive": "Default Keepalive Interval", "default-keep-alive": "Default Keepalive Interval",
"default-dns": "Default DNS Servers",
"button-show-config": "Show configuration", "button-show-config": "Show configuration",
"button-download-config": "Download configuration", "button-download-config": "Download configuration",
"button-store-config": "Store configuration for wg-quick", "button-store-config": "Store configuration for wg-quick",
@ -220,6 +221,16 @@
"button-delete-text": "Delete the passkey. You will not be able to log in with this passkey anymore.", "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-title": "Register Passkey",
"button-register-text": "Register a new Passkey to secure your account." "button-register-text": "Register a new Passkey to secure your account."
},
"password": {
"headline": "Password Settings",
"abstract": "Here you can change your password.",
"current-label": "Current Password",
"new-label": "New Password",
"new-confirm-label": "Confirm New Password",
"change-button-text": "Change Password",
"invalid-confirm-label": "Passwords do not match",
"weak-label": "Password is too weak"
} }
}, },
"audit": { "audit": {
@ -462,6 +473,8 @@
"section-config": "Configuration", "section-config": "Configuration",
"identifier": "Identifier", "identifier": "Identifier",
"ip": "IP Addresses", "ip": "IP Addresses",
"allowed-ip": "Allowed IP Addresses",
"extra-allowed-ip": "Server Side Allowed IP Addresses",
"user": "Associated User", "user": "Associated User",
"notes": "Notes", "notes": "Notes",
"expiry-status": "Expires At", "expiry-status": "Expires At",
@ -474,6 +487,8 @@
"handshake": "Last Handshake", "handshake": "Last Handshake",
"connected-since": "Connected since", "connected-since": "Connected since",
"endpoint": "Endpoint", "endpoint": "Endpoint",
"endpoint-key": "Endpoint Public Key",
"keepalive": "Persistent Keepalive",
"button-download": "Download configuration", "button-download": "Download configuration",
"button-email": "Send configuration via E-Mail", "button-email": "Send configuration via E-Mail",
"style-label": "Configuration Style" "style-label": "Configuration Style"

View File

@ -115,6 +115,7 @@ export const interfaceStore = defineStore('interfaces', {
return apiWrapper.post(`${baseUrl}/new`, formData) return apiWrapper.post(`${baseUrl}/new`, formData)
.then(iface => { .then(iface => {
this.interfaces.push(iface) this.interfaces.push(iface)
this.selected = iface.Identifier
this.fetching = false this.fetching = false
}) })
.catch(error => { .catch(error => {

View File

@ -126,9 +126,14 @@ export const peerStore = defineStore('peers', {
if (!statsResponse) { if (!statsResponse) {
this.stats = {} this.stats = {}
this.statsEnabled = false this.statsEnabled = false
} } else {
this.stats = statsResponse.Stats this.stats = statsResponse.Stats
this.statsEnabled = statsResponse.Enabled this.statsEnabled = statsResponse.Enabled
}
},
async Reset() {
this.setPeers([])
this.setStats(undefined)
}, },
async PreparePeer(interfaceId) { async PreparePeer(interfaceId) {
return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/prepare`) return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/prepare`)
@ -186,10 +191,10 @@ export const peerStore = defineStore('peers', {
async LoadStats(interfaceId) { async LoadStats(interfaceId) {
// if no interfaceId is given, use the currently selected interface // if no interfaceId is given, use the currently selected interface
if (!interfaceId) { if (!interfaceId) {
interfaceId = interfaceStore().GetSelected.Identifier if (!interfaceStore().GetSelected || !interfaceStore().GetSelected.Identifier) {
if (!interfaceId) {
return // no interface, nothing to load return // no interface, nothing to load
} }
interfaceId = interfaceStore().GetSelected.Identifier
} }
this.fetching = true this.fetching = true
@ -260,10 +265,10 @@ export const peerStore = defineStore('peers', {
async LoadPeers(interfaceId) { async LoadPeers(interfaceId) {
// if no interfaceId is given, use the currently selected interface // if no interfaceId is given, use the currently selected interface
if (!interfaceId) { if (!interfaceId) {
interfaceId = interfaceStore().GetSelected.Identifier if (!interfaceStore().GetSelected || !interfaceStore().GetSelected.Identifier) {
if (!interfaceId) {
return // no interface, nothing to load return // no interface, nothing to load
} }
interfaceId = interfaceStore().GetSelected.Identifier
} }
this.fetching = true this.fetching = true

View File

@ -151,6 +151,17 @@ export const profileStore = defineStore('profile', {
}) })
}) })
}, },
async changePassword(formData) {
this.fetching = true
let currentUser = authStore().user.Identifier
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/change-password`, formData)
.then(this.fetching = false)
.catch(error => {
this.fetching = false;
console.log("Failed to change password for ", currentUser, ": ", error);
throw new Error(error);
});
},
async LoadPeers() { async LoadPeers() {
this.fetching = true this.fetching = true
let currentUser = authStore().user.Identifier let currentUser = authStore().user.Identifier

View File

@ -217,14 +217,14 @@ onMounted(async () => {
<td>{{ $t('interfaces.interface.ip') }}:</td> <td>{{ $t('interfaces.interface.ip') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Addresses" :key="addr">{{addr}}</span></td> <td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Addresses" :key="addr">{{addr}}</span></td>
</tr> </tr>
<tr>
<td>{{ $t('interfaces.interface.dns') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Dns" :key="addr">{{addr}}</span></td>
</tr>
<tr> <tr>
<td>{{ $t('interfaces.interface.mtu') }}:</td> <td>{{ $t('interfaces.interface.mtu') }}:</td>
<td>{{interfaces.GetSelected.Mtu}}</td> <td>{{interfaces.GetSelected.Mtu}}</td>
</tr> </tr>
<tr>
<td>{{ $t('interfaces.interface.default-dns') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.PeerDefDns" :key="addr">{{addr}}</span></td>
</tr>
<tr> <tr>
<td>{{ $t('interfaces.interface.default-keep-alive') }}:</td> <td>{{ $t('interfaces.interface.default-keep-alive') }}:</td>
<td>{{interfaces.GetSelected.PeerDefPersistentKeepalive}}</td> <td>{{interfaces.GetSelected.PeerDefPersistentKeepalive}}</td>

View File

@ -1,8 +1,9 @@
<script setup> <script setup>
import {onMounted, ref} from "vue"; import {computed, onMounted, ref} from "vue";
import { profileStore } from "@/stores/profile"; import { profileStore } from "@/stores/profile";
import { settingsStore } from "@/stores/settings"; import { settingsStore } from "@/stores/settings";
import { authStore } from "../stores/auth"; import { authStore } from "../stores/auth";
import {notify} from "@kyvg/vue3-notification";
const profile = profileStore() const profile = profileStore()
const settings = settingsStore() const settings = settingsStore()
@ -34,6 +35,45 @@ async function saveRename(credential) {
console.error("Failed to rename credential:", error); console.error("Failed to rename credential:", error);
} }
} }
const pwFormData = ref({
OldPassword: '',
Password: '',
PasswordRepeat: '',
})
const passwordWeak = computed(() => {
return pwFormData.value.Password && pwFormData.value.Password.length > 0 && pwFormData.value.Password.length < settings.Setting('MinPasswordLength')
})
const passwordChangeAllowed = computed(() => {
return pwFormData.value.Password && pwFormData.value.Password.length >= settings.Setting('MinPasswordLength') &&
pwFormData.value.Password === pwFormData.value.PasswordRepeat &&
pwFormData.value.OldPassword && pwFormData.value.OldPassword.length > 0 && pwFormData.value.OldPassword !== pwFormData.value.Password;
})
const updatePassword = async () => {
try {
await profile.changePassword(pwFormData.value);
pwFormData.value.OldPassword = '';
pwFormData.value.Password = '';
pwFormData.value.PasswordRepeat = '';
notify({
title: "Password changed!",
text: "Your password has been changed successfully.",
type: 'success',
});
} catch (e) {
notify({
title: "Failed to update password!",
text: e.toString(),
type: 'error',
})
}
}
</script> </script>
<template> <template>
@ -43,54 +83,47 @@ async function saveRename(credential) {
<p class="lead">{{ $t('settings.abstract') }}</p> <p class="lead">{{ $t('settings.abstract') }}</p>
<div v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')"> <div class="card border-secondary p-5 mt-5" v-if="profile.user.Source === 'db'">
<div class="card border-secondary p-5" v-if="profile.user.ApiToken"> <h2 class="display-7">{{ $t('settings.password.headline') }}</h2>
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2> <p class="lead">{{ $t('settings.password.abstract') }}</p>
<p class="lead">{{ $t('settings.api.abstract') }}</p>
<hr class="my-4"> <hr class="my-4">
<p>{{ $t('settings.api.active-description') }}</p>
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-6">
<div class="form-group"> <div class="form-group">
<label class="form-label mt-4">{{ $t('settings.api.user-label') }}</label> <label class="form-label mt-4" for="oldpw">{{ $t('settings.password.current-label') }}</label>
<input v-model="profile.user.Identifier" class="form-control" :placeholder="$t('settings.api.user-placeholder')" type="text" readonly> <input id="oldpw" v-model="pwFormData.OldPassword" class="form-control" :class="{ 'is-invalid': pwFormData.Password && !pwFormData.OldPassword }" type="password">
</div>
</div>
<div class="col-6">
</div>
</div>
<div class="row">
<div class="col-6">
<div class="form-group has-success">
<label class="form-label mt-4" for="newpw">{{ $t('settings.password.new-label') }}</label>
<input id="newpw" v-model="pwFormData.Password" class="form-control" :class="{ 'is-invalid': passwordWeak, 'is-valid': pwFormData.Password !== '' && !passwordWeak }" type="password">
<div class="invalid-feedback" v-if="passwordWeak">{{ $t('settings.password.weak-label') }}</div>
</div> </div>
</div> </div>
<div class="col-6"> <div class="col-6">
<div class="form-group"> <div class="form-group">
<label class="form-label mt-4">{{ $t('settings.api.token-label') }}</label> <label class="form-label mt-4" for="confirmnewpw">{{ $t('settings.password.new-confirm-label') }}</label>
<input v-model="profile.user.ApiToken" class="form-control" :placeholder="$t('settings.api.token-placeholder')" type="text" readonly> <input id="confirmnewpw" v-model="pwFormData.PasswordRepeat" class="form-control" :class="{ 'is-invalid': pwFormData.PasswordRepeat !== ''&& pwFormData.Password !== pwFormData.PasswordRepeat, 'is-valid': pwFormData.PasswordRepeat !== '' && pwFormData.Password === pwFormData.PasswordRepeat && !passwordWeak }" type="password">
</div> <div class="invalid-feedback" v-if="pwFormData.PasswordRepeat !== ''&& pwFormData.Password !== pwFormData.PasswordRepeat">{{ $t('settings.password.invalid-confirm-label') }}</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="form-group">
<p class="form-label mt-4">{{ $t('settings.api.token-created-label') }} {{profile.user.ApiTokenCreated}}</p>
</div> </div>
</div> </div>
</div> </div>
<div class="row mt-5"> <div class="row mt-5">
<div class="col-6"> <div class="col-6">
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching"> <button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="updatePassword" :disabled="profile.isFetching || !passwordChangeAllowed">
<i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }} <i class="fa-solid fa-floppy-disk"></i> {{ $t('settings.password.change-button-text') }}
</button> </button>
</div> </div>
<div class="col-6"> <div class="col-6">
<a href="/api/v1/doc.html" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
</div> </div>
</div> </div>
</div> </div>
<div class="card border-secondary p-5" v-else>
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
<p class="lead">{{ $t('settings.api.abstract') }}</p>
<hr class="my-4">
<p>{{ $t('settings.api.inactive-description') }}</p>
<button class="btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching">
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }}
</button>
</div>
</div>
<div class="card border-secondary p-5 mt-5" v-if="settings.Setting('WebAuthnEnabled')"> <div class="card border-secondary p-5 mt-5" v-if="settings.Setting('WebAuthnEnabled')">
<h2 class="display-7">{{ $t('settings.webauthn.headline') }}</h2> <h2 class="display-7">{{ $t('settings.webauthn.headline') }}</h2>
@ -173,4 +206,53 @@ async function saveRename(credential) {
</div> </div>
</div> </div>
<div class="mt-5" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')">
<div class="card border-secondary p-5" v-if="profile.user.ApiToken">
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
<p class="lead">{{ $t('settings.api.abstract') }}</p>
<hr class="my-4">
<p>{{ $t('settings.api.active-description') }}</p>
<div class="row">
<div class="col-6">
<div class="form-group">
<label class="form-label mt-4">{{ $t('settings.api.user-label') }}</label>
<input v-model="profile.user.Identifier" class="form-control" :placeholder="$t('settings.api.user-placeholder')" type="text" readonly>
</div>
</div>
<div class="col-6">
<div class="form-group">
<label class="form-label mt-4">{{ $t('settings.api.token-label') }}</label>
<input v-model="profile.user.ApiToken" class="form-control" :placeholder="$t('settings.api.token-placeholder')" type="text" readonly>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="form-group">
<p class="form-label mt-4">{{ $t('settings.api.token-created-label') }} {{profile.user.ApiTokenCreated}}</p>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-6">
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching">
<i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }}
</button>
</div>
<div class="col-6">
<a href="/api/v1/doc.html" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
</div>
</div>
</div>
<div class="card border-secondary p-5" v-else>
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
<p class="lead">{{ $t('settings.api.abstract') }}</p>
<hr class="my-4">
<p>{{ $t('settings.api.inactive-description') }}</p>
<button class="btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching">
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }}
</button>
</div>
</div>
</template> </template>

60
go.mod
View File

@ -5,11 +5,11 @@ go 1.24.0
require ( require (
github.com/a8m/envsubst v1.4.3 github.com/a8m/envsubst v1.4.3
github.com/alexedwards/scs/v2 v2.9.0 github.com/alexedwards/scs/v2 v2.9.0
github.com/coreos/go-oidc/v3 v3.15.0 github.com/coreos/go-oidc/v3 v3.16.0
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-pkgz/routegroup v1.5.3 github.com/go-pkgz/routegroup v1.5.3
github.com/go-playground/validator/v10 v10.27.0 github.com/go-playground/validator/v10 v10.28.0
github.com/go-webauthn/webauthn v0.14.0 github.com/go-webauthn/webauthn v0.14.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/prometheus-community/pro-bing v0.7.0 github.com/prometheus-community/pro-bing v0.7.0
@ -21,9 +21,9 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0 github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.5 github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1 github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.42.0 golang.org/x/crypto v0.43.0
golang.org/x/oauth2 v0.31.0 golang.org/x/oauth2 v0.32.0
golang.org/x/sys v0.36.0 golang.org/x/sys v0.37.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
@ -44,22 +44,17 @@ require (
github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/glebarez/go-sqlite v1.22.0 // 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-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.22.0 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/spec v0.22.0 // indirect
github.com/go-openapi/swag v0.24.1 // indirect github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/cmdutils v0.24.0 // indirect github.com/go-openapi/swag/jsonname v0.25.1 // indirect
github.com/go-openapi/swag/conv v0.24.0 // indirect github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
github.com/go-openapi/swag/fileutils v0.24.0 // indirect github.com/go-openapi/swag/loading v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.24.0 // indirect github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/jsonutils v0.24.0 // indirect github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/loading v0.24.0 // indirect github.com/go-openapi/swag/yamlutils v0.25.1 // 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/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.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-sql-driver/mysql v1.9.3 // indirect
@ -69,16 +64,14 @@ require (
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect github.com/google/go-tpm v0.9.6 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/leodido/go-urn v1.4.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/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.8.0 // indirect github.com/mdlayher/netlink v1.8.0 // indirect
@ -96,18 +89,19 @@ require (
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
golang.org/x/mod v0.28.0 // indirect golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.43.0 // indirect golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.36.0 // indirect golang.org/x/tools v0.37.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/protobuf v1.36.8 // indirect google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.66.8 // indirect modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.2 // indirect modernc.org/sqlite v1.39.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect
) )

137
go.sum
View File

@ -30,16 +30,16 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY= github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY=
github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU= 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-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= 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/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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg= github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -58,40 +58,33 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= 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 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-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.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM= github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU= github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA= github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8= github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A= github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I= github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8= github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik= github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c= github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak= github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q= github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts= github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0= github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc= github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk= github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk= github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc= github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w= github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
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 h1:IvH1KLcQkMap9jucQGBlef3IBloxSAe8USUFvxShFqs=
github.com/go-pkgz/routegroup v1.5.3/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg= 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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@ -100,8 +93,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/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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 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.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= 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-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 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
@ -122,8 +115,8 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO
github.com/google/go-cmp v0.6.0/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm v0.9.6/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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -158,8 +151,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 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/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 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/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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@ -173,8 +164,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
@ -257,10 +246,10 @@ 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/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 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= 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.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 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.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.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
@ -273,10 +262,10 @@ 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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -302,10 +291,10 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.32.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-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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -335,8 +324,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.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.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.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -365,23 +354,23 @@ 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.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 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.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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.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.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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 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.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= 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 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 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@ -401,18 +390,18 @@ gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQ
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 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.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.26.5/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 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= 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.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.28/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.8 h1:/awsvTnyN/sNjvJm6S3lb7KZw5WV4ly/sBEG7ZUzmIE= modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.8/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k= modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@ -421,8 +410,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 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 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@ -9,6 +9,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
"slices"
"strings" "strings"
"time" "time"
@ -85,7 +86,7 @@ func NewLocalController(cfg *config.Config) (*LocalController, error) {
nl: nl, nl: nl,
shellCmd: "bash", // we only support bash at the moment shellCmd: "bash", // we only support bash at the moment
resolvConfIfacePrefix: "tun.", // WireGuard interfaces have a tun. prefix in resolvconf resolvConfIfacePrefix: cfg.Backend.LocalResolvconfPrefix, // WireGuard interfaces have a tun. prefix in resolvconf
} }
return repo, nil return repo, nil
@ -546,7 +547,11 @@ func (c LocalController) deletePeer(deviceId domain.InterfaceIdentifier, id doma
// region wg-quick-related // region wg-quick-related
func (c LocalController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error { func (c LocalController) ExecuteInterfaceHook(
_ context.Context,
id domain.InterfaceIdentifier,
hookCmd string,
) error {
if hookCmd == "" { if hookCmd == "" {
return nil return nil
} }
@ -560,7 +565,7 @@ func (c LocalController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hoo
return nil return nil
} }
func (c LocalController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error { func (c LocalController) SetDNS(_ context.Context, id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error {
if dnsStr == "" && dnsSearchStr == "" { if dnsStr == "" && dnsSearchStr == "" {
return nil return nil
} }
@ -589,7 +594,7 @@ func (c LocalController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearch
return nil return nil
} }
func (c LocalController) UnsetDNS(id domain.InterfaceIdentifier) error { func (c LocalController) UnsetDNS(_ context.Context, id domain.InterfaceIdentifier, _, _ string) error {
dnsCommand := "resolvconf -d %resPref%i -f" dnsCommand := "resolvconf -d %resPref%i -f"
err := c.exec(dnsCommand, id) err := c.exec(dnsCommand, id)
@ -611,7 +616,7 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
if len(stdin) > 0 { if len(stdin) > 0 {
b := &bytes.Buffer{} b := &bytes.Buffer{}
for _, ln := range stdin { for _, ln := range stdin {
if _, err := fmt.Fprint(b, ln); err != nil { if _, err := fmt.Fprint(b, ln+"\n"); err != nil {
return err return err
} }
} }
@ -619,6 +624,8 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
} }
out, err := cmd.CombinedOutput() // execute and wait for output out, err := cmd.CombinedOutput() // execute and wait for output
if err != nil { if err != nil {
slog.Warn("failed to executed shell command",
"command", commandWithInterfaceName, "stdin", stdin, "output", string(out), "error", err)
return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err) return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err)
} }
slog.Debug("executed shell command", slog.Debug("executed shell command",
@ -631,49 +638,116 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
// region routing-related // region routing-related
func (c LocalController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error { // SetRoutes sets the routes for the given interface. If no routes are provided, the function is a no-op.
// update fwmark rules func (c LocalController) SetRoutes(_ context.Context, info domain.RoutingTableInfo) error {
if err := c.setFwMarkRules(rules); err != nil { interfaceId := info.Interface.Identifier
return err slog.Debug("setting linux routes", "interface", interfaceId, "table", info.Table, "fwMark", info.FwMark,
"cidrs", info.AllowedIps)
link, err := c.nl.LinkByName(string(interfaceId))
if err != nil {
return fmt.Errorf("failed to find physical link for %s: %w", interfaceId, err)
} }
// update main rule cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps)
if err := c.setMainRule(rules); err != nil { realTable, realFwMark, err := c.getOrCreateRoutingTableAndFwMark(link, info.Table, info.FwMark)
return err if err != nil {
return fmt.Errorf("failed to get or create routing table and fwmark for %s: %w", interfaceId, err)
}
wgDev, err := c.wg.Device(string(interfaceId))
if err != nil {
return fmt.Errorf("failed to get wg device for %s: %w", interfaceId, err)
}
currentFwMark := wgDev.FirewallMark
if int(realFwMark) != currentFwMark {
slog.Debug("updating fwmark for interface", "interface", interfaceId, "oldFwMark", currentFwMark,
"newFwMark", realFwMark, "oldTable", info.Table, "newTable", realTable)
if err := c.updateFwMarkOnInterface(interfaceId, int(realFwMark)); err != nil {
return fmt.Errorf("failed to update fwmark for interface %s to %d: %w", interfaceId, realFwMark, err)
}
} }
// cleanup old main rules if err := c.setRoutesForFamily(interfaceId, link, netlink.FAMILY_V4, realTable, realFwMark, cidrsV4); err != nil {
if err := c.cleanupMainRule(rules); err != nil { return fmt.Errorf("failed to set v4 routes: %w", err)
return err }
if err := c.setRoutesForFamily(interfaceId, link, netlink.FAMILY_V6, realTable, realFwMark, cidrsV6); err != nil {
return fmt.Errorf("failed to set v6 routes: %w", err)
} }
return nil return nil
} }
func (c LocalController) setFwMarkRules(rules []domain.RouteRule) error { func (c LocalController) setRoutesForFamily(
for _, rule := range rules { interfaceId domain.InterfaceIdentifier,
existingRules, err := c.nl.RuleList(int(rule.IpFamily)) link netlink.Link,
family int,
table int,
fwMark uint32,
cidrs []domain.Cidr,
) error {
// first create or update the routes
for _, cidr := range cidrs {
err := c.nl.RouteReplace(&netlink.Route{
LinkIndex: link.Attrs().Index,
Dst: cidr.IpNet(),
Table: table,
Scope: unix.RT_SCOPE_LINK,
Type: unix.RTN_UNICAST,
})
if err != nil { if err != nil {
return fmt.Errorf("failed to get existing rules for family %s: %w", rule.IpFamily, err) return fmt.Errorf("failed to add/update route %s on table %d for interface %s: %w",
} cidr.String(), table, interfaceId, err)
ruleExists := false
for _, existingRule := range existingRules {
if rule.FwMark == existingRule.Mark && rule.Table == existingRule.Table {
ruleExists = true
break
} }
} }
if ruleExists { // next remove old routes
continue // rule already exists, no need to recreate it rawRoutes, err := c.nl.RouteListFiltered(family, &netlink.Route{
LinkIndex: link.Attrs().Index,
Table: unix.RT_TABLE_UNSPEC, // all tables
Scope: unix.RT_SCOPE_LINK,
Type: unix.RTN_UNICAST,
}, netlink.RT_FILTER_TABLE|netlink.RT_FILTER_TYPE|netlink.RT_FILTER_OIF)
if err != nil {
return fmt.Errorf("failed to fetch raw routes for interface %s and family-id %d: %w",
interfaceId, family, err)
}
for _, rawRoute := range rawRoutes {
if rawRoute.Dst == nil { // handle default route
var netlinkAddr domain.Cidr
if family == netlink.FAMILY_V4 {
netlinkAddr, _ = domain.CidrFromString("0.0.0.0/0")
} else {
netlinkAddr, _ = domain.CidrFromString("::/0")
}
rawRoute.Dst = netlinkAddr.IpNet()
} }
// create a missing rule route := domain.CidrFromIpNet(*rawRoute.Dst)
if slices.Contains(cidrs, route) {
continue
}
if err := c.nl.RouteDel(&rawRoute); err != nil {
return fmt.Errorf("failed to remove deprecated route %s from interface %s: %w", route, interfaceId, err)
}
}
// next, update route rules for normal routes
if table == 0 {
return nil // no need to update route rules as we are using the default table
}
existingRules, err := c.nl.RuleList(family)
if err != nil {
return fmt.Errorf("failed to get existing rules for family-id %d: %w", family, err)
}
ruleExists := slices.ContainsFunc(existingRules, func(rule netlink.Rule) bool {
return rule.Mark == fwMark && rule.Table == table
})
if !ruleExists {
if err := c.nl.RuleAdd(&netlink.Rule{ if err := c.nl.RuleAdd(&netlink.Rule{
Family: int(rule.IpFamily), Family: family,
Table: rule.Table, Table: table,
Mark: rule.FwMark, Mark: fwMark,
Invert: true, Invert: true,
SuppressIfgroup: -1, SuppressIfgroup: -1,
SuppressPrefixlen: -1, SuppressPrefixlen: -1,
@ -682,15 +756,102 @@ func (c LocalController) setFwMarkRules(rules []domain.RouteRule) error {
Goto: -1, Goto: -1,
Flow: -1, Flow: -1,
}); err != nil { }); err != nil {
return fmt.Errorf("failed to setup %s rule for fwmark %d and table %d: %w", return fmt.Errorf("failed to setup rule for fwmark %d and table %d for family-id %d: %w",
rule.IpFamily, rule.FwMark, rule.Table, err) fwMark, table, family, err)
} }
} }
mainRuleExists := slices.ContainsFunc(existingRules, func(rule netlink.Rule) bool {
return rule.SuppressPrefixlen == 0 && rule.Table == unix.RT_TABLE_MAIN
})
if !mainRuleExists && domain.ContainsDefaultRoute(cidrs) {
err = c.nl.RuleAdd(&netlink.Rule{
Family: family,
Table: unix.RT_TABLE_MAIN,
SuppressIfgroup: -1,
SuppressPrefixlen: 0,
Priority: c.getMainRulePriority(existingRules),
Mark: 0,
Mask: nil,
Goto: -1,
Flow: -1,
})
}
// finally, clean up extra main rules - only one rule is allowed
existingRules, err = c.nl.RuleList(family)
if err != nil {
return fmt.Errorf("failed to get existing main rules for family-id %d: %w", family, err)
}
mainRuleCount := 0
for _, rule := range existingRules {
if rule.SuppressPrefixlen == 0 && rule.Table == unix.RT_TABLE_MAIN {
mainRuleCount++
}
if mainRuleCount > 1 {
if err := c.nl.RuleDel(&rule); err != nil {
return fmt.Errorf("failed to remove extra main rule for family-id %d: %w", family, err)
}
}
}
return nil return nil
} }
func (c LocalController) getOrCreateRoutingTableAndFwMark(
link netlink.Link,
tableIn int,
fwMarkIn uint32,
) (
table int,
fwmark uint32,
err error,
) {
table = tableIn
fwmark = fwMarkIn
if fwmark == 0 {
// generate a new (temporary) firewall mark based on the interface index
fwmark = uint32(c.cfg.Advanced.RouteTableOffset + link.Attrs().Index)
}
if table == 0 {
table = int(fwmark) // generate a new routing table base on interface index
}
return
}
func (c LocalController) updateFwMarkOnInterface(interfaceId domain.InterfaceIdentifier, fwMark int) error {
// apply the new fwmark to the wireguard interface
err := c.wg.ConfigureDevice(string(interfaceId), wgtypes.Config{
FirewallMark: &fwMark,
})
if err != nil {
return fmt.Errorf("failed to update fwmark of interface %s to: %d: %w", interfaceId, fwMark, err)
}
return nil
}
func (c LocalController) getMainRulePriority(existingRules []netlink.Rule) int {
prio := c.cfg.Advanced.RulePrioOffset
for {
isFresh := true
for _, existingRule := range existingRules {
if existingRule.Priority == prio {
isFresh = false
break
}
}
if isFresh {
break
} else {
prio++
}
}
return prio
}
func (c LocalController) getRulePriority(existingRules []netlink.Rule) int { func (c LocalController) getRulePriority(existingRules []netlink.Rule) int {
prio := 32700 // linux main rule has a priority of 32766 prio := 32700 // linux main rule has a prio of 32766
for { for {
isFresh := true isFresh := true
for _, existingRule := range existingRules { for _, existingRule := range existingRules {
@ -708,126 +869,145 @@ func (c LocalController) getRulePriority(existingRules []netlink.Rule) int {
return prio return prio
} }
func (c LocalController) setMainRule(rules []domain.RouteRule) error { // RemoveRoutes removes the routes for the given interface. If no routes are provided, the function is a no-op.
var family domain.IpFamily func (c LocalController) RemoveRoutes(_ context.Context, info domain.RoutingTableInfo) error {
shouldHaveMainRule := false interfaceId := info.Interface.Identifier
for _, rule := range rules { slog.Debug("removing linux routes", "interface", interfaceId, "table", info.Table, "fwMark", info.FwMark,
family = rule.IpFamily "cidrs", info.AllowedIps)
if rule.HasDefault == true {
shouldHaveMainRule = true
break
}
}
if !shouldHaveMainRule {
return nil
}
existingRules, err := c.nl.RuleList(int(family)) wgDev, err := c.wg.Device(string(interfaceId))
if err != nil { if err != nil {
return fmt.Errorf("failed to get existing rules for family %s: %w", family, err) slog.Debug("wg device already removed, route cleanup might be incomplete", "interface", interfaceId)
wgDev = nil
}
link, err := c.nl.LinkByName(string(interfaceId))
if err != nil {
slog.Debug("physical link already removed, route cleanup might be incomplete", "interface", interfaceId)
link = nil
} }
ruleExists := false fwMark := info.FwMark
for _, existingRule := range existingRules { if wgDev != nil && info.FwMark == 0 {
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 { fwMark = uint32(wgDev.FirewallMark)
ruleExists = true }
break table := info.Table
if wgDev != nil && info.Table == 0 {
table = wgDev.FirewallMark // use the fwMark as table, this is the default behavior
}
linkIndex := -1
if link != nil {
linkIndex = link.Attrs().Index
}
cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps)
realTable, realFwMark, err := c.getOrCreateRoutingTableAndFwMark(link, table, fwMark)
if err != nil {
return fmt.Errorf("failed to get or create routing table and fwmark for %s: %w", interfaceId, err)
}
if linkIndex > 0 {
err = c.removeRoutesForFamily(interfaceId, link, netlink.FAMILY_V4, realTable, realFwMark, cidrsV4)
if err != nil {
return fmt.Errorf("failed to remove v4 routes: %w", err)
}
err = c.removeRoutesForFamily(interfaceId, link, netlink.FAMILY_V6, realTable, realFwMark, cidrsV6)
if err != nil {
return fmt.Errorf("failed to remove v6 routes: %w", err)
} }
} }
if ruleExists { if table > 0 {
return nil // rule already exists, skip re-creation err = c.removeRouteRulesForTable(netlink.FAMILY_V4, realTable)
if err != nil {
return fmt.Errorf("failed to remove v4 route rules for %s: %w", interfaceId, err)
}
err = c.removeRouteRulesForTable(netlink.FAMILY_V6, realTable)
if err != nil {
return fmt.Errorf("failed to remove v6 route rules for %s: %w", interfaceId, err)
} }
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 return nil
} }
func (c LocalController) getMainRulePriority(existingRules []netlink.Rule) int { func (c LocalController) removeRoutesForFamily(
priority := c.cfg.Advanced.RulePrioOffset interfaceId domain.InterfaceIdentifier,
for { link netlink.Link,
isFresh := true family int,
for _, existingRule := range existingRules { table int,
if existingRule.Priority == priority { fwMark uint32,
isFresh = false cidrs []domain.Cidr,
break ) error {
} // first remove all rules
} existingRules, err := c.nl.RuleList(family)
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 { if err != nil {
return fmt.Errorf("failed to get existing rules for family %s: %w", family, err) return fmt.Errorf("failed to get existing rules for family %d: %w", family, err)
} }
shouldHaveMainRule := false
for _, rule := range rules {
if rule.HasDefault == true {
shouldHaveMainRule = true
break
}
}
mainRules := 0
for _, existingRule := range existingRules { for _, existingRule := range existingRules {
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 { if fwMark == existingRule.Mark && table == existingRule.Table {
mainRules++ existingRule.Family = family // set family, somehow the RuleList method does not populate the family field
}
}
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 { if err := c.nl.RuleDel(&existingRule); err != nil {
return fmt.Errorf("failed to delete main rule: %w", err) return fmt.Errorf("failed to delete old fwmark rule: %w", err)
} }
removalCount--
} }
} }
// next remove all routes
rawRoutes, err := c.nl.RouteListFiltered(family, &netlink.Route{
LinkIndex: link.Attrs().Index,
Table: unix.RT_TABLE_UNSPEC, // all tables
Scope: unix.RT_SCOPE_LINK,
Type: unix.RTN_UNICAST,
}, netlink.RT_FILTER_TABLE|netlink.RT_FILTER_TYPE|netlink.RT_FILTER_OIF)
if err != nil {
return fmt.Errorf("failed to fetch raw routes for interface %s and family-id %d: %w",
interfaceId, family, err)
}
for _, rawRoute := range rawRoutes {
if rawRoute.Dst == nil { // handle default route
var netlinkAddr domain.Cidr
if family == netlink.FAMILY_V4 {
netlinkAddr, _ = domain.CidrFromString("0.0.0.0/0")
} else {
netlinkAddr, _ = domain.CidrFromString("::/0")
}
rawRoute.Dst = netlinkAddr.IpNet()
}
if rawRoute.Table != table {
continue // ignore routes from other tables
}
route := domain.CidrFromIpNet(*rawRoute.Dst)
if !slices.Contains(cidrs, route) {
continue // only remove routes that were previously added
}
if err := c.nl.RouteDel(&rawRoute); err != nil {
return fmt.Errorf("failed to remove old route %s from interface %s: %w", route, interfaceId, err)
}
} }
return nil return nil
} }
func (c LocalController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error { func (c LocalController) removeRouteRulesForTable(
// TODO implement me family int,
panic("implement me") table int,
) error {
existingRules, err := c.nl.RuleList(family)
if err != nil {
return fmt.Errorf("failed to get existing route rules for family-id %d: %w", family, err)
}
for _, existingRule := range existingRules {
if existingRule.Table == table {
err := c.nl.RuleDel(&existingRule)
if err != nil {
return fmt.Errorf("failed to delete old rule for table %d and family-id %d: %w", table, family, err)
}
}
}
return nil
} }
// endregion routing-related // endregion routing-related

View File

@ -15,6 +15,9 @@ import (
"github.com/h44z/wg-portal/internal/lowlevel" "github.com/h44z/wg-portal/internal/lowlevel"
) )
const MikrotikRouteDistance = 5
const MikrotikDefaultRoutingTable = "main"
type MikrotikController struct { type MikrotikController struct {
coreCfg *config.Config coreCfg *config.Config
cfg *config.BackendMikrotik cfg *config.BackendMikrotik
@ -24,6 +27,7 @@ type MikrotikController struct {
// Add mutexes to prevent race conditions // Add mutexes to prevent race conditions
interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*sync.Mutex interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*sync.Mutex
peerMutexes sync.Map // map[domain.PeerIdentifier]*sync.Mutex peerMutexes sync.Map // map[domain.PeerIdentifier]*sync.Mutex
coreMutex sync.Mutex // for updating the core configuration such as routing table or DNS settings
} }
func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikController, error) { func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikController, error) {
@ -40,6 +44,7 @@ func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik)
interfaceMutexes: sync.Map{}, interfaceMutexes: sync.Map{},
peerMutexes: sync.Map{}, peerMutexes: sync.Map{},
coreMutex: sync.Mutex{},
}, nil }, nil
} }
@ -763,33 +768,404 @@ func (c *MikrotikController) DeletePeer(
// region wg-quick-related // region wg-quick-related
func (c *MikrotikController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error { func (c *MikrotikController) ExecuteInterfaceHook(
_ context.Context,
_ domain.InterfaceIdentifier,
_ string,
) error {
// TODO implement me // TODO implement me
panic("implement me") slog.Error("interface hooks are not yet supported for Mikrotik backends, please open an issue on GitHub")
return nil
} }
func (c *MikrotikController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error { func (c *MikrotikController) SetDNS(
// TODO implement me ctx context.Context,
panic("implement me") _ domain.InterfaceIdentifier,
dnsStr, _ string,
) error {
// Lock the interface to prevent concurrent modifications
c.coreMutex.Lock()
defer c.coreMutex.Unlock()
// check if the server is already configured
wgReply := c.client.Get(ctx, "/ip/dns", &lowlevel.MikrotikRequestOptions{
PropList: []string{"servers"},
})
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("unable to find WireGuard dns settings: %v", wgReply.Error)
} }
func (c *MikrotikController) UnsetDNS(id domain.InterfaceIdentifier) error { var existingServers []string
// TODO implement me existingServers = append(existingServers, strings.Split(wgReply.Data.GetString("servers"), ",")...)
panic("implement me")
newServers := strings.Split(dnsStr, ",")
mergedServers := slices.Clone(existingServers)
for _, s := range newServers {
if s == "" {
continue
}
if !slices.Contains(mergedServers, s) {
mergedServers = append(mergedServers, s)
}
}
mergedServersStr := strings.Join(mergedServers, ",")
reply := c.client.ExecList(ctx, "/ip/dns/set", lowlevel.GenericJsonObject{
"servers": mergedServersStr,
})
if reply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("failed to set DNS servers: %s: %v", mergedServersStr, reply.Error)
}
return nil
}
func (c *MikrotikController) UnsetDNS(
ctx context.Context,
_ domain.InterfaceIdentifier,
dnsStr, _ string,
) error {
// Lock the interface to prevent concurrent modifications
c.coreMutex.Lock()
defer c.coreMutex.Unlock()
// retrieve current DNS settings
wgReply := c.client.Get(ctx, "/ip/dns", &lowlevel.MikrotikRequestOptions{
PropList: []string{"servers"},
})
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("unable to find WireGuard dns settings: %v", wgReply.Error)
}
var existingServers []string
existingServers = append(existingServers, strings.Split(wgReply.Data.GetString("servers"), ",")...)
oldServers := strings.Split(dnsStr, ",")
mergedServers := make([]string, 0, len(existingServers))
for _, s := range existingServers {
if s == "" {
continue
}
if !slices.Contains(oldServers, s) {
mergedServers = append(mergedServers, s) // only keep the servers that are not in the old list
}
}
mergedServersStr := strings.Join(mergedServers, ",")
reply := c.client.ExecList(ctx, "/ip/dns/set", lowlevel.GenericJsonObject{
"servers": mergedServersStr,
})
if reply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("failed to set DNS servers: %s: %v", mergedServersStr, reply.Error)
}
return nil
} }
// endregion wg-quick-related // endregion wg-quick-related
// region routing-related // region routing-related
func (c *MikrotikController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error { // SetRoutes sets the routes for the given interface. If no routes are provided, the function is a no-op.
// TODO implement me func (c *MikrotikController) SetRoutes(ctx context.Context, info domain.RoutingTableInfo) error {
panic("implement me") interfaceId := info.Interface.Identifier
slog.Debug("setting mikrotik routes", "interface", interfaceId, "table", info.TableStr, "cidrs", info.AllowedIps)
// Mikrotik needs some time to apply the changes.
// If we don't wait, the routes might get created multiple times as the dynamic routes are not yet available.
time.Sleep(2 * time.Second)
tableName, err := c.getOrCreateRoutingTables(ctx, info.Interface.Identifier, info.TableStr)
if err != nil {
return fmt.Errorf("failed to get or create routing table for %s: %v", interfaceId, err)
} }
func (c *MikrotikController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error { cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps)
// TODO implement me
panic("implement me") err = c.setRoutesForFamily(ctx, interfaceId, false, tableName, cidrsV4)
if err != nil {
return fmt.Errorf("failed to set IPv4 routes for %s: %v", interfaceId, err)
}
err = c.setRoutesForFamily(ctx, interfaceId, true, tableName, cidrsV6)
if err != nil {
return fmt.Errorf("failed to set IPv6 routes for %s: %v", interfaceId, err)
}
return nil
}
func (c *MikrotikController) resolveRouteTableName(name string) string {
name = strings.TrimSpace(name)
var mikrotikTableName string
switch strings.ToLower(name) {
case "", "0":
mikrotikTableName = MikrotikDefaultRoutingTable
case MikrotikDefaultRoutingTable:
return fmt.Sprintf("wgportal-%s",
MikrotikDefaultRoutingTable) // if the Mikrotik Main table should be used, the table-name should be left empty or set to "0".
default:
mikrotikTableName = name
}
return mikrotikTableName
}
func (c *MikrotikController) getOrCreateRoutingTables(
ctx context.Context,
interfaceId domain.InterfaceIdentifier,
table string,
) (string, error) {
// retrieve current routing tables
wgReply := c.client.Query(ctx, "/routing/table", &lowlevel.MikrotikRequestOptions{
PropList: []string{
".id", "dynamic", "fib", "name",
},
})
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
return "", fmt.Errorf("unable to query routing tables: %v", wgReply.Error)
}
wantedTableName := c.resolveRouteTableName(table)
// check if the table already exists
for _, table := range wgReply.Data {
if table.GetString("name") == wantedTableName {
return wantedTableName, nil // already exists, nothing to do
}
}
// create the table if it does not exist
createReply := c.client.Create(ctx, "/routing/table", lowlevel.GenericJsonObject{
"name": wantedTableName,
"comment": fmt.Sprintf("Routing Table for %s", interfaceId),
"fib": strconv.FormatBool(true),
})
if createReply.Status != lowlevel.MikrotikApiStatusOk {
return "", fmt.Errorf("failed to create routing table %s: %v", wantedTableName, createReply.Error)
}
return wantedTableName, nil
}
func (c *MikrotikController) setRoutesForFamily(
ctx context.Context,
interfaceId domain.InterfaceIdentifier,
ipV6 bool,
table string,
cidrs []domain.Cidr,
) error {
apiPath := "/ip/route"
if ipV6 {
apiPath = "/ipv6/route"
}
// retrieve current routes
wgReply := c.client.Query(ctx, apiPath, &lowlevel.MikrotikRequestOptions{
PropList: []string{
".id", "disabled", "inactive", "distance", "dst-address", "dynamic", "gateway", "immediate-gw",
"routing-table", "scope", "target-scope", "client-dns", "comment", "disabled", "responder",
},
Filters: map[string]string{
"gateway": string(interfaceId),
},
})
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("unable to find WireGuard IP route settings (v6=%t): %v", ipV6, wgReply.Error)
}
// first create or update the routes
for _, cidr := range cidrs {
// check if the route already exists
exists := false
for _, route := range wgReply.Data {
existingRoute, err := domain.CidrFromString(route.GetString("dst-address"))
if err != nil {
slog.Warn("failed to parse route destination address",
"cidr", route.GetString("dst-address"), "error", err)
continue
}
if existingRoute.EqualPrefix(cidr) && route.GetString("routing-table") == table {
exists = true
break
}
}
if exists {
continue // route already exists, nothing to do
}
// create the route
reply := c.client.Create(ctx, apiPath, lowlevel.GenericJsonObject{
"gateway": string(interfaceId),
"dst-address": cidr.String(),
"distance": strconv.Itoa(MikrotikRouteDistance),
"disabled": strconv.FormatBool(false),
"routing-table": table,
})
if reply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("failed to create new route %s via %s: %v", cidr.String(), interfaceId, reply.Error)
}
}
// finally, remove the routes that are not in the new list
for _, route := range wgReply.Data {
if route.GetBool("dynamic") {
continue // dynamic routes are not managed by the controller, nothing to do
}
existingRoute, err := domain.CidrFromString(route.GetString("dst-address"))
if err != nil {
slog.Warn("failed to parse route destination address",
"cidr", route.GetString("dst-address"), "error", err)
continue
}
valid := false
for _, cidr := range cidrs {
if existingRoute.EqualPrefix(cidr) {
valid = true
break
}
}
if valid {
continue // route is still valid, nothing to do
}
// remove the route
reply := c.client.Delete(ctx, apiPath+"/"+route.GetString(".id"))
if reply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("failed to remove outdated route %s: %v", existingRoute.String(), reply.Error)
}
}
return nil
}
// RemoveRoutes removes the routes for the given interface. If no routes are provided, the function is a no-op.
func (c *MikrotikController) RemoveRoutes(ctx context.Context, info domain.RoutingTableInfo) error {
interfaceId := info.Interface.Identifier
slog.Debug("removing mikrotik routes", "interface", interfaceId, "table", info.TableStr, "cidrs", info.AllowedIps)
tableName := c.resolveRouteTableName(info.TableStr)
cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps)
err := c.removeRoutesForFamily(ctx, interfaceId, false, tableName, cidrsV4)
if err != nil {
return fmt.Errorf("failed to remove IPv4 routes for %s: %v", interfaceId, err)
}
err = c.removeRoutesForFamily(ctx, interfaceId, true, tableName, cidrsV6)
if err != nil {
return fmt.Errorf("failed to remove IPv6 routes for %s: %v", interfaceId, err)
}
err = c.removeRoutingTable(ctx, tableName)
if err != nil {
return fmt.Errorf("failed to remove routing table for %s: %v", interfaceId, err)
}
return nil
}
func (c *MikrotikController) removeRoutesForFamily(
ctx context.Context,
interfaceId domain.InterfaceIdentifier,
ipV6 bool,
table string,
cidrs []domain.Cidr,
) error {
apiPath := "/ip/route"
if ipV6 {
apiPath = "/ipv6/route"
}
// retrieve current routes
wgReply := c.client.Query(ctx, apiPath, &lowlevel.MikrotikRequestOptions{
PropList: []string{
".id", "disabled", "inactive", "distance", "dst-address", "dynamic", "gateway", "immediate-gw",
"routing-table", "scope", "target-scope", "client-dns", "comment", "disabled", "responder",
},
Filters: map[string]string{
"gateway": string(interfaceId),
},
})
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("unable to find WireGuard IP route settings (v6=%t): %v", ipV6, wgReply.Error)
}
// remove the routes from the list
for _, route := range wgReply.Data {
if route.GetBool("dynamic") {
continue // dynamic routes are not managed by the controller, nothing to do
}
existingRoute, err := domain.CidrFromString(route.GetString("dst-address"))
if err != nil {
slog.Warn("failed to parse route destination address",
"cidr", route.GetString("dst-address"), "error", err)
continue
}
remove := false
for _, cidr := range cidrs {
if existingRoute.EqualPrefix(cidr) && route.GetString("routing-table") == table {
remove = true
break
}
}
if !remove {
continue // route is still valid, nothing to do
}
// remove the route
reply := c.client.Delete(ctx, apiPath+"/"+route.GetString(".id"))
if reply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("failed to remove old route %s: %v", existingRoute.String(), reply.Error)
}
}
return nil
}
func (c *MikrotikController) removeRoutingTable(
ctx context.Context,
table string,
) error {
if table == MikrotikDefaultRoutingTable {
return nil // we cannot remove the default table
}
// retrieve current routing tables
wgReply := c.client.Query(ctx, "/routing/table", &lowlevel.MikrotikRequestOptions{
PropList: []string{
".id", "dynamic", "fib", "name",
},
})
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("unable to query routing tables: %v", wgReply.Error)
}
for _, existingTable := range wgReply.Data {
if existingTable.GetBool("dynamic") {
continue // dynamic tables are not managed by the controller, nothing to do
}
if existingTable.GetString("name") != table {
continue // not the table we want to remove
}
// remove the table
reply := c.client.Delete(ctx, "/routing/table/"+existingTable.GetString(".id"))
if reply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("failed to remove routing table %s: %v", table, reply.Error)
}
return nil
}
return nil
} }
// endregion routing-related // endregion routing-related

View File

@ -1,113 +0,0 @@
package adapters
import (
"bytes"
"fmt"
"log/slog"
"os/exec"
"strings"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain"
)
// WgQuickRepo implements higher level wg-quick like interactions like setting DNS, routing tables or interface hooks.
type WgQuickRepo struct {
shellCmd string
resolvConfIfacePrefix string
}
// NewWgQuickRepo creates a new WgQuickRepo instance.
func NewWgQuickRepo() *WgQuickRepo {
return &WgQuickRepo{
shellCmd: "bash",
resolvConfIfacePrefix: "tun.",
}
}
// ExecuteInterfaceHook executes the given hook command.
// The hook command can contain the following placeholders:
//
// %i: the interface identifier.
func (r *WgQuickRepo) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error {
if hookCmd == "" {
return nil
}
slog.Debug("executing interface hook", "interface", id, "hook", hookCmd)
err := r.exec(hookCmd, id)
if err != nil {
return fmt.Errorf("failed to exec hook: %w", err)
}
return nil
}
// SetDNS sets the DNS settings for the given interface. It uses resolvconf to set the DNS settings.
func (r *WgQuickRepo) 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 := r.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
}
// UnsetDNS unsets the DNS settings for the given interface. It uses resolvconf to unset the DNS settings.
func (r *WgQuickRepo) UnsetDNS(id domain.InterfaceIdentifier) error {
dnsCommand := "resolvconf -d %resPref%i -f"
err := r.exec(dnsCommand, id)
if err != nil {
return fmt.Errorf("failed to unset dns settings: %w", err)
}
return nil
}
func (r *WgQuickRepo) replaceCommandPlaceHolders(command string, interfaceId domain.InterfaceIdentifier) string {
command = strings.ReplaceAll(command, "%resPref", r.resolvConfIfacePrefix)
return strings.ReplaceAll(command, "%i", string(interfaceId))
}
func (r *WgQuickRepo) exec(command string, interfaceId domain.InterfaceIdentifier, stdin ...string) error {
commandWithInterfaceName := r.replaceCommandPlaceHolders(command, interfaceId)
cmd := exec.Command(r.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
}

View File

@ -1550,6 +1550,38 @@
} }
} }
}, },
"/user/{id}/change-password": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Change the password for the given user.",
"operationId": "users_handleChangePasswordPost",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/{id}/interfaces": { "/user/{id}/interfaces": {
"get": { "get": {
"produces": [ "produces": [
@ -2159,6 +2191,10 @@
} }
] ]
}, },
"UserDisplayName": {
"description": "the owner display name",
"type": "string"
},
"UserIdentifier": { "UserIdentifier": {
"description": "the owner", "description": "the owner",
"type": "string" "type": "string"

View File

@ -322,6 +322,9 @@ definitions:
allOf: allOf:
- $ref: '#/definitions/model.ConfigOption-string' - $ref: '#/definitions/model.ConfigOption-string'
description: the routing table description: the routing table
UserDisplayName:
description: the owner display name
type: string
UserIdentifier: UserIdentifier:
description: the owner description: the owner
type: string type: string
@ -1442,6 +1445,27 @@ paths:
summary: Enable the REST API for the given user. summary: Enable the REST API for the given user.
tags: tags:
- Users - Users
/user/{id}/change-password:
post:
operationId: users_handleChangePasswordPost
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.User'
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Change the password for the given user.
tags:
- Users
/user/{id}/interfaces: /user/{id}/interfaces:
get: get:
operationId: users_handleInterfacesGet operationId: users_handleInterfacesGet

View File

@ -17,11 +17,6 @@
"paths": { "paths": {
"/interface/all": { "/interface/all": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -52,16 +47,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/interface/by-id/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/interface/by-id/{id}": {
"get": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -110,14 +105,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}, },
"put": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
},
"put": {
"description": "This endpoint updates an existing interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).", "description": "This endpoint updates an existing interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).",
"produces": [ "produces": [
"application/json" "application/json"
@ -182,14 +177,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}, },
"delete": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
},
"delete": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -241,16 +236,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/interface/new": {
"post": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/interface/new": {
"post": {
"description": "This endpoint creates a new interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).", "description": "This endpoint creates a new interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).",
"produces": [ "produces": [
"application/json" "application/json"
@ -308,16 +303,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/interface/prepare": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/interface/prepare": {
"get": {
"description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).", "description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).",
"produces": [ "produces": [
"application/json" "application/json"
@ -352,16 +347,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/metrics/by-interface/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/metrics/by-interface/{id}": {
"get": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -410,16 +405,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/metrics/by-peer/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/metrics/by-peer/{id}": {
"get": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -468,16 +463,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/metrics/by-user/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/metrics/by-user/{id}": {
"get": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -526,16 +521,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/peer/by-id/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/peer/by-id/{id}": {
"get": {
"description": "Normal users can only access their own records. Admins can access all records.", "description": "Normal users can only access their own records. Admins can access all records.",
"produces": [ "produces": [
"application/json" "application/json"
@ -585,14 +580,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}, },
"put": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
},
"put": {
"description": "Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).", "description": "Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
"produces": [ "produces": [
"application/json" "application/json"
@ -657,14 +652,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}, },
"delete": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
},
"delete": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -716,16 +711,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/peer/by-interface/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/peer/by-interface/{id}": {
"get": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -765,16 +760,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/peer/by-user/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/peer/by-user/{id}": {
"get": {
"description": "Normal users can only access their own records. Admins can access all records.", "description": "Normal users can only access their own records. Admins can access all records.",
"produces": [ "produces": [
"application/json" "application/json"
@ -815,16 +810,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/peer/new": {
"post": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/peer/new": {
"post": {
"description": "Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).", "description": "Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
"produces": [ "produces": [
"application/json" "application/json"
@ -882,16 +877,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/peer/prepare/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/peer/prepare/{id}": {
"get": {
"description": "This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.", "description": "This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.",
"produces": [ "produces": [
"application/json" "application/json"
@ -947,16 +942,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/provisioning/data/peer-config": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/provisioning/data/peer-config": {
"get": {
"description": "Normal users can only access their own record. Admins can access all records.", "description": "Normal users can only access their own record. Admins can access all records.",
"produces": [ "produces": [
"text/plain", "text/plain",
@ -1013,16 +1008,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/provisioning/data/peer-qr": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/provisioning/data/peer-qr": {
"get": {
"description": "Normal users can only access their own record. Admins can access all records.", "description": "Normal users can only access their own record. Admins can access all records.",
"produces": [ "produces": [
"image/png", "image/png",
@ -1079,16 +1074,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/provisioning/data/user-info": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/provisioning/data/user-info": {
"get": {
"description": "Normal users can only access their own record. Admins can access all records.", "description": "Normal users can only access their own record. Admins can access all records.",
"produces": [ "produces": [
"application/json" "application/json"
@ -1149,16 +1144,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/provisioning/new-peer": {
"post": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/provisioning/new-peer": {
"post": {
"description": "Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.", "description": "Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.",
"produces": [ "produces": [
"application/json" "application/json"
@ -1216,16 +1211,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/user/all": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/user/all": {
"get": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -1256,16 +1251,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/user/by-id/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/user/by-id/{id}": {
"get": {
"description": "Normal users can only access their own record. Admins can access all records.", "description": "Normal users can only access their own record. Admins can access all records.",
"produces": [ "produces": [
"application/json" "application/json"
@ -1315,14 +1310,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}, },
"put": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
},
"put": {
"description": "Only admins can update existing records.", "description": "Only admins can update existing records.",
"produces": [ "produces": [
"application/json" "application/json"
@ -1387,14 +1382,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}, },
"delete": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
},
"delete": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@ -1446,16 +1441,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}
}
}, },
"/user/new": {
"post": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/user/new": {
"post": {
"description": "Only admins can create new records.", "description": "Only admins can create new records.",
"produces": [ "produces": [
"application/json" "application/json"
@ -1513,7 +1508,12 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
},
"security": [
{
"BasicAuth": []
} }
]
} }
} }
}, },

View File

@ -2,6 +2,8 @@ package backend
import ( import (
"context" "context"
"fmt"
"strings"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
@ -70,6 +72,44 @@ func (u UserService) DeactivateApi(ctx context.Context, id domain.UserIdentifier
return u.users.DeactivateApi(ctx, id) return u.users.DeactivateApi(ctx, id)
} }
func (u UserService) ChangePassword(ctx context.Context, id domain.UserIdentifier, oldPassword, newPassword string) (*domain.User, error) {
oldPassword = strings.TrimSpace(oldPassword)
newPassword = strings.TrimSpace(newPassword)
if newPassword == "" {
return nil, fmt.Errorf("new password must not be empty")
}
// ensure that the new password is different from the old one
if oldPassword == newPassword {
return nil, fmt.Errorf("new password must be different from the old one")
}
user, err := u.users.GetUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
// ensure that the user uses the database backend; otherwise we can't change the password
if user.Source != domain.UserSourceDatabase {
return nil, fmt.Errorf("user source %s does not support password changes", user.Source)
}
// validate old password
if user.CheckPassword(oldPassword) != nil {
return nil, fmt.Errorf("current password is invalid")
}
user.Password = domain.PrivateString(newPassword)
// ensure that the new password is strong enough
if err := user.HasWeakPassword(u.cfg.Auth.MinPasswordLength); err != nil {
return nil, err
}
return u.users.UpdateUser(ctx, user)
}
func (u UserService) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) { func (u UserService) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) {
return u.wg.GetUserPeers(ctx, id) return u.wg.GetUserPeers(ctx, id)
} }

View File

@ -28,6 +28,8 @@ type UserService interface {
ActivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) ActivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
// DeactivateApi disables the API for the user with the given id. // DeactivateApi disables the API for the user with the given id.
DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
// ChangePassword changes the password for the user with the given id.
ChangePassword(ctx context.Context, id domain.UserIdentifier, oldPassword, newPassword string) (*domain.User, error)
// GetUserPeers returns all peers for the given user. // GetUserPeers returns all peers for the given user.
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
// GetUserPeerStats returns all peer stats for the given user. // GetUserPeerStats returns all peer stats for the given user.
@ -75,6 +77,7 @@ func (e UserEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet())
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost())
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost())
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/change-password", e.handleChangePasswordPost())
} }
// handleAllGet returns a gorm Handler function. // handleAllGet returns a gorm Handler function.
@ -391,3 +394,68 @@ func (e UserEndpoint) handleApiDisablePost() http.HandlerFunc {
respond.JSON(w, http.StatusOK, model.NewUser(user, false)) respond.JSON(w, http.StatusOK, model.NewUser(user, false))
} }
} }
// handleChangePasswordPost returns a gorm Handler function.
//
// @ID users_handleChangePasswordPost
// @Tags Users
// @Summary Change the password for the given user.
// @Produce json
// @Success 200 {object} model.User
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/{id}/change-password [post]
func (e UserEndpoint) handleChangePasswordPost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userId := Base64UrlDecode(request.Path(r, "id"))
if userId == "" {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
return
}
var passwordData struct {
OldPassword string `json:"OldPassword"`
Password string `json:"Password"`
PasswordRepeat string `json:"PasswordRepeat"`
}
if err := request.BodyJson(r, &passwordData); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
if passwordData.OldPassword == "" {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "old password missing"})
return
}
if passwordData.Password == "" {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "new password missing"})
return
}
if passwordData.OldPassword == passwordData.Password {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "password did not change"})
return
}
if passwordData.Password != passwordData.PasswordRepeat {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "password mismatch"})
return
}
user, err := e.userService.ChangePassword(r.Context(), domain.UserIdentifier(userId),
passwordData.OldPassword, passwordData.Password)
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.JSON(w, http.StatusOK, model.NewUser(user, false))
}
}

View File

@ -27,6 +27,7 @@ type PlainOauthAuthenticator struct {
userAdminMapping *config.OauthAdminMapping userAdminMapping *config.OauthAdminMapping
registrationEnabled bool registrationEnabled bool
userInfoLogging bool userInfoLogging bool
sensitiveInfoLogging bool
allowedDomains []string allowedDomains []string
} }
@ -57,6 +58,7 @@ func newPlainOauthAuthenticator(
provider.userAdminMapping = &cfg.AdminMapping provider.userAdminMapping = &cfg.AdminMapping
provider.registrationEnabled = cfg.RegistrationEnabled provider.registrationEnabled = cfg.RegistrationEnabled
provider.userInfoLogging = cfg.LogUserInfo provider.userInfoLogging = cfg.LogUserInfo
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains provider.allowedDomains = cfg.AllowedDomains
return provider, nil return provider, nil
@ -110,6 +112,10 @@ func (p PlainOauthAuthenticator) GetUserInfo(
response, err := p.client.Do(req) response, err := p.client.Do(req)
if err != nil { if err != nil {
if p.sensitiveInfoLogging {
slog.Debug("OAuth: failed to get user info", "endpoint", p.userInfoEndpoint,
"token", token, "error", err)
}
return nil, fmt.Errorf("failed to get user info: %w", err) return nil, fmt.Errorf("failed to get user info: %w", err)
} }
defer internal.LogClose(response.Body) defer internal.LogClose(response.Body)
@ -121,11 +127,15 @@ func (p PlainOauthAuthenticator) GetUserInfo(
var userFields map[string]any var userFields map[string]any
err = json.Unmarshal(contents, &userFields) err = json.Unmarshal(contents, &userFields)
if err != nil { if err != nil {
if p.sensitiveInfoLogging {
slog.Debug("OAuth: failed to parse user info", "endpoint", p.userInfoEndpoint,
"token", token, "contents", contents, "error", err)
}
return nil, fmt.Errorf("failed to parse user info: %w", err) return nil, fmt.Errorf("failed to parse user info: %w", err)
} }
if p.userInfoLogging { if p.userInfoLogging {
slog.Debug("OAuth user info", slog.Debug("OAuth: user info debug",
"source", p.name, "source", p.name,
"info", string(contents)) "info", string(contents))
} }

View File

@ -24,6 +24,7 @@ type OidcAuthenticator struct {
userAdminMapping *config.OauthAdminMapping userAdminMapping *config.OauthAdminMapping
registrationEnabled bool registrationEnabled bool
userInfoLogging bool userInfoLogging bool
sensitiveInfoLogging bool
allowedDomains []string allowedDomains []string
} }
@ -58,6 +59,7 @@ func newOidcAuthenticator(
provider.userAdminMapping = &cfg.AdminMapping provider.userAdminMapping = &cfg.AdminMapping
provider.registrationEnabled = cfg.RegistrationEnabled provider.registrationEnabled = cfg.RegistrationEnabled
provider.userInfoLogging = cfg.LogUserInfo provider.userInfoLogging = cfg.LogUserInfo
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains provider.allowedDomains = cfg.AllowedDomains
return provider, nil return provider, nil
@ -102,24 +104,40 @@ func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token,
) { ) {
rawIDToken, ok := token.Extra("id_token").(string) rawIDToken, ok := token.Extra("id_token").(string)
if !ok { if !ok {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: token does not contain id_token", "token", token, "nonce", nonce)
}
return nil, errors.New("token does not contain id_token") return nil, errors.New("token does not contain id_token")
} }
idToken, err := o.verifier.Verify(ctx, rawIDToken) idToken, err := o.verifier.Verify(ctx, rawIDToken)
if err != nil { if err != nil {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: failed to validate id_token", "token", token, "id_token", rawIDToken, "nonce", nonce,
"error",
err)
}
return nil, fmt.Errorf("failed to validate id_token: %w", err) return nil, fmt.Errorf("failed to validate id_token: %w", err)
} }
if idToken.Nonce != nonce { if idToken.Nonce != nonce {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: id_token nonce mismatch", "token", token, "id_token", idToken, "nonce", nonce)
}
return nil, errors.New("nonce mismatch") return nil, errors.New("nonce mismatch")
} }
var tokenFields map[string]any var tokenFields map[string]any
if err = idToken.Claims(&tokenFields); err != nil { if err = idToken.Claims(&tokenFields); err != nil {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: failed to parse extra claims", "token", token, "id_token", idToken, "nonce", nonce,
"error",
err)
}
return nil, fmt.Errorf("failed to parse extra claims: %w", err) return nil, fmt.Errorf("failed to parse extra claims: %w", err)
} }
if o.userInfoLogging { if o.userInfoLogging {
contents, _ := json.Marshal(tokenFields) contents, _ := json.Marshal(tokenFields)
slog.Debug("OIDC user info", slog.Debug("OIDC: user info debug",
"source", o.name, "source", o.name,
"info", string(contents)) "info", string(contents))
} }

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net/mail"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
@ -101,29 +102,15 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, style string,
} }
if peer.UserIdentifier == "" { if peer.UserIdentifier == "" {
slog.Debug("skipping peer email", return fmt.Errorf("peer %s has no user linked, no email is sent", peerId)
"peer", peerId,
"reason", "no user linked")
continue
} }
user, err := m.users.GetUser(ctx, peer.UserIdentifier) email, user := m.resolveEmail(ctx, peer)
if err != nil { if email == "" {
slog.Debug("skipping peer email", return fmt.Errorf("peer %s has no valid email address, no email is sent", peerId)
"peer", peerId,
"reason", "unable to fetch user",
"error", err)
continue
} }
if user.Email == "" { err = m.sendPeerEmail(ctx, linkOnly, style, &user, peer)
slog.Debug("skipping peer email",
"peer", peerId,
"reason", "user has no mail address")
continue
}
err = m.sendPeerEmail(ctx, linkOnly, style, user, peer)
if err != nil { if err != nil {
return fmt.Errorf("failed to send peer email for %s: %w", peerId, err) return fmt.Errorf("failed to send peer email for %s: %w", peerId, err)
} }
@ -194,3 +181,37 @@ func (m Manager) sendPeerEmail(
return nil return nil
} }
func (m Manager) resolveEmail(ctx context.Context, peer *domain.Peer) (string, domain.User) {
user, err := m.users.GetUser(ctx, peer.UserIdentifier)
if err != nil {
if m.cfg.Mail.AllowPeerEmail {
_, err := mail.ParseAddress(string(peer.UserIdentifier)) // test if the user identifier is a valid email address
if err == nil {
slog.Debug("peer email: using user-identifier as email",
"peer", peer.Identifier, "email", peer.UserIdentifier)
return string(peer.UserIdentifier), domain.User{}
} else {
slog.Debug("peer email: skipping peer email",
"peer", peer.Identifier,
"reason", "peer has no user linked and user-identifier is not a valid email address")
return "", domain.User{}
}
} else {
slog.Debug("peer email: skipping peer email",
"peer", peer.Identifier,
"reason", "user has no user linked")
return "", domain.User{}
}
}
if user.Email == "" {
slog.Debug("peer email: skipping peer email",
"peer", peer.Identifier,
"reason", "user has no mail address")
return "", domain.User{}
}
slog.Debug("peer email: using user email", "peer", peer.Identifier, "email", user.Email)
return user.Email, *user
}

View File

@ -4,25 +4,23 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"sync"
"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/app" "github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/lowlevel"
) )
// region dependencies // region dependencies
type ControllerManager interface {
// GetController returns the controller for the given interface.
GetController(iface domain.Interface) domain.InterfaceController
}
type InterfaceAndPeerDatabaseRepo interface { type InterfaceAndPeerDatabaseRepo interface {
// GetAllInterfaces returns all interfaces // GetInterface returns the interface with the given identifier.
GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error)
// GetInterfacePeers returns all peers for a given interface
GetInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.Peer, error)
} }
type EventBus interface { type EventBus interface {
@ -30,6 +28,13 @@ type EventBus interface {
Subscribe(topic string, fn interface{}) error Subscribe(topic string, fn interface{}) error
} }
type RoutesController interface {
// SetRoutes sets the routes for the given interface. If no routes are provided, the function is a no-op.
SetRoutes(ctx context.Context, info domain.RoutingTableInfo) error
// RemoveRoutes removes the routes for the given interface. If no routes are provided, the function is a no-op.
RemoveRoutes(ctx context.Context, info domain.RoutingTableInfo) error
}
// endregion dependencies // endregion dependencies
type routeRuleInfo struct { type routeRuleInfo struct {
@ -46,27 +51,26 @@ type Manager struct {
cfg *config.Config cfg *config.Config
bus EventBus bus EventBus
wg lowlevel.WireGuardClient
nl lowlevel.NetlinkClient
db InterfaceAndPeerDatabaseRepo db InterfaceAndPeerDatabaseRepo
wgController ControllerManager
mux *sync.Mutex
} }
// NewRouteManager creates a new route manager instance. // NewRouteManager creates a new route manager instance.
func NewRouteManager(cfg *config.Config, bus EventBus, db InterfaceAndPeerDatabaseRepo) (*Manager, error) { func NewRouteManager(
wg, err := wgctrl.New() cfg *config.Config,
if err != nil { bus EventBus,
panic("failed to init wgctrl: " + err.Error()) db InterfaceAndPeerDatabaseRepo,
} wgController ControllerManager,
) (*Manager, error) {
nl := &lowlevel.NetlinkManager{}
m := &Manager{ m := &Manager{
cfg: cfg, cfg: cfg,
bus: bus, bus: bus,
db: db, db: db,
wg: wg, wgController: wgController,
nl: nl, mux: &sync.Mutex{},
} }
m.connectToMessageBus() m.connectToMessageBus()
@ -85,419 +89,82 @@ func (m Manager) StartBackgroundJobs(_ context.Context) {
// this is a no-op for now // this is a no-op for now
} }
func (m Manager) handleRouteUpdateEvent(srcDescription string) { func (m Manager) handleRouteUpdateEvent(info domain.RoutingTableInfo) {
slog.Debug("handling route update event", "source", srcDescription) m.mux.Lock() // ensure that only one route update is processed at a time
defer m.mux.Unlock()
err := m.syncRoutes(context.Background()) slog.Debug("handling route update event", "info", info.String())
if err != nil {
slog.Error("failed to synchronize routes", if !info.ManagementEnabled() {
"source", srcDescription, return // route management disabled
"error", err)
} }
slog.Debug("routes synchronized", "source", srcDescription) err := m.syncRoutes(context.Background(), info)
if err != nil {
slog.Error("failed to synchronize routes",
"info", info.String(), "error", err)
return
}
slog.Debug("routes synchronized", "info", info.String())
} }
func (m Manager) handleRouteRemoveEvent(info domain.RoutingTableInfo) { func (m Manager) handleRouteRemoveEvent(info domain.RoutingTableInfo) {
m.mux.Lock() // ensure that only one route update is processed at a time
defer m.mux.Unlock()
slog.Debug("handling route remove event", "info", info.String()) slog.Debug("handling route remove event", "info", info.String())
if !info.ManagementEnabled() { if !info.ManagementEnabled() {
return // route management disabled return // route management disabled
} }
if err := m.removeFwMarkRules(info.FwMark, info.GetRoutingTable(), netlink.FAMILY_V4); err != nil { err := m.removeRoutes(context.Background(), info)
slog.Error("failed to remove v4 fwmark rules", "error", err)
}
if err := m.removeFwMarkRules(info.FwMark, info.GetRoutingTable(), netlink.FAMILY_V6); err != nil {
slog.Error("failed to remove v6 fwmark rules", "error", err)
}
slog.Debug("routes removed", "table", info.String())
}
func (m Manager) syncRoutes(ctx context.Context) error {
interfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil { if err != nil {
return fmt.Errorf("failed to find all interfaces: %w", err) slog.Error("failed to synchronize routes",
} "info", info.String(), "error", err)
rules := map[int][]routeRuleInfo{
netlink.FAMILY_V4: nil,
netlink.FAMILY_V6: nil,
}
for _, iface := range interfaces {
if iface.IsDisabled() {
continue // disabled interface does not need route entries
}
if !iface.ManageRoutingTable() {
continue
}
peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
if err != nil {
return fmt.Errorf("failed to find peers for %s: %w", iface.Identifier, err)
}
allowedIPs := iface.GetAllowedIPs(peers)
defRouteV4, defRouteV6 := m.containsDefaultRoute(allowedIPs)
link, err := m.nl.LinkByName(string(iface.Identifier))
if err != nil {
return fmt.Errorf("failed to find physical link for %s: %w", iface.Identifier, err)
}
table, fwmark, err := m.getRoutingTableAndFwMark(&iface, link)
if err != nil {
return fmt.Errorf("failed to get table and fwmark for %s: %w", iface.Identifier, err)
}
if err := m.setInterfaceRoutes(link, table, allowedIPs); err != nil {
return fmt.Errorf("failed to set routes for %s: %w", iface.Identifier, err)
}
if err := m.removeDeprecatedRoutes(link, netlink.FAMILY_V4, allowedIPs); err != nil {
return fmt.Errorf("failed to remove deprecated v4 routes for %s: %w", iface.Identifier, err)
}
if err := m.removeDeprecatedRoutes(link, netlink.FAMILY_V6, allowedIPs); err != nil {
return fmt.Errorf("failed to remove deprecated v6 routes for %s: %w", iface.Identifier, err)
}
if table != 0 {
rules[netlink.FAMILY_V4] = append(rules[netlink.FAMILY_V4], routeRuleInfo{
ifaceId: iface.Identifier,
fwMark: fwmark,
table: table,
family: netlink.FAMILY_V4,
hasDefault: defRouteV4,
})
}
if table != 0 {
rules[netlink.FAMILY_V6] = append(rules[netlink.FAMILY_V6], routeRuleInfo{
ifaceId: iface.Identifier,
fwMark: fwmark,
table: table,
family: netlink.FAMILY_V6,
hasDefault: defRouteV6,
})
}
}
return m.syncRouteRules(rules)
}
func (m Manager) syncRouteRules(allRules map[int][]routeRuleInfo) error {
for family, rules := range allRules {
// update fwmark rules
if err := m.setFwMarkRules(rules, family); err != nil {
return err
}
// update main rule
if err := m.setMainRule(rules, family); err != nil {
return err
}
// cleanup old main rules
if err := m.cleanupMainRule(rules, family); err != nil {
return err
}
}
return nil
}
func (m Manager) setFwMarkRules(rules []routeRuleInfo, family int) error {
for _, rule := range rules {
existingRules, err := m.nl.RuleList(family)
if err != nil {
return fmt.Errorf("failed to get existing rules for family %d: %w", family, 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 missing rule
if err := m.nl.RuleAdd(&netlink.Rule{
Family: family,
Table: rule.table,
Mark: rule.fwMark,
Invert: true,
SuppressIfgroup: -1,
SuppressPrefixlen: -1,
Priority: m.getRulePriority(existingRules),
Mask: nil,
Goto: -1,
Flow: -1,
}); err != nil {
return fmt.Errorf("failed to setup rule for fwmark %d and table %d: %w", rule.fwMark, rule.table, err)
}
}
return nil
}
func (m Manager) removeFwMarkRules(fwmark uint32, table int, family int) error {
existingRules, err := m.nl.RuleList(family)
if err != nil {
return fmt.Errorf("failed to get existing rules for family %d: %w", family, err)
}
for _, existingRule := range existingRules {
if fwmark == existingRule.Mark && table == existingRule.Table {
existingRule.Family = family // set family, somehow the RuleList method does not populate the family field
if err := m.nl.RuleDel(&existingRule); err != nil {
return fmt.Errorf("failed to delete fwmark rule: %w", err)
}
}
}
return nil
}
func (m Manager) setMainRule(rules []routeRuleInfo, family int) error {
shouldHaveMainRule := false
for _, rule := range rules {
if rule.hasDefault == true {
shouldHaveMainRule = true
break
}
}
if !shouldHaveMainRule {
return nil
}
existingRules, err := m.nl.RuleList(family)
if err != nil {
return fmt.Errorf("failed to get existing rules for family %d: %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 := m.nl.RuleAdd(&netlink.Rule{
Family: family,
Table: unix.RT_TABLE_MAIN,
SuppressIfgroup: -1,
SuppressPrefixlen: 0,
Priority: m.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 (m Manager) cleanupMainRule(rules []routeRuleInfo, family int) error {
existingRules, err := m.nl.RuleList(family)
if err != nil {
return fmt.Errorf("failed to get existing rules for family %d: %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 = family // set family, somehow the RuleList method does not populate the family field
if err := m.nl.RuleDel(&existingRule); err != nil {
return fmt.Errorf("failed to delete main rule: %w", err)
}
removalCount--
}
}
}
return nil
}
func (m Manager) getMainRulePriority(existingRules []netlink.Rule) int {
prio := m.cfg.Advanced.RulePrioOffset
for {
isFresh := true
for _, existingRule := range existingRules {
if existingRule.Priority == prio {
isFresh = false
break
}
}
if isFresh {
break
} else {
prio++
}
}
return prio
}
func (m Manager) getRulePriority(existingRules []netlink.Rule) int {
prio := 32700 // linux main rule has a prio of 32766
for {
isFresh := true
for _, existingRule := range existingRules {
if existingRule.Priority == prio {
isFresh = false
break
}
}
if isFresh {
break
} else {
prio--
}
}
return prio
}
func (m Manager) setInterfaceRoutes(link netlink.Link, table int, allowedIPs []domain.Cidr) error {
for _, allowedIP := range allowedIPs {
err := m.nl.RouteReplace(&netlink.Route{
LinkIndex: link.Attrs().Index,
Dst: allowedIP.IpNet(),
Table: table,
Scope: unix.RT_SCOPE_LINK,
Type: unix.RTN_UNICAST,
})
if err != nil {
return fmt.Errorf("failed to add/update route %s: %w", allowedIP.String(), err)
}
}
return nil
}
func (m Manager) removeDeprecatedRoutes(link netlink.Link, family int, allowedIPs []domain.Cidr) error {
rawRoutes, err := m.nl.RouteListFiltered(family, &netlink.Route{
LinkIndex: link.Attrs().Index,
Table: unix.RT_TABLE_UNSPEC, // all tables
Scope: unix.RT_SCOPE_LINK,
Type: unix.RTN_UNICAST,
}, netlink.RT_FILTER_TABLE|netlink.RT_FILTER_TYPE|netlink.RT_FILTER_OIF)
if err != nil {
return fmt.Errorf("failed to fetch raw routes: %w", err)
}
for _, rawRoute := range rawRoutes {
if rawRoute.Dst == nil { // handle default route
var netlinkAddr domain.Cidr
if family == netlink.FAMILY_V4 {
netlinkAddr, _ = domain.CidrFromString("0.0.0.0/0")
} else {
netlinkAddr, _ = domain.CidrFromString("::/0")
}
rawRoute.Dst = netlinkAddr.IpNet()
}
netlinkAddr := domain.CidrFromIpNet(*rawRoute.Dst)
remove := true
for _, allowedIP := range allowedIPs {
if netlinkAddr == allowedIP {
remove = false
break
}
}
if !remove {
continue
}
err := m.nl.RouteDel(&rawRoute)
if err != nil {
return fmt.Errorf("failed to remove deprecated route %s: %w", netlinkAddr.String(), err)
}
}
return nil
}
func (m Manager) getRoutingTableAndFwMark(iface *domain.Interface, link netlink.Link) (
table int,
fwmark uint32,
err error,
) {
table = iface.GetRoutingTable()
fwmark = iface.FirewallMark
if fwmark == 0 {
// generate a new (temporary) firewall mark based on the interface index
fwmark = uint32(m.cfg.Advanced.RouteTableOffset + link.Attrs().Index)
slog.Debug("using fwmark to handle routes",
"interface", iface.Identifier,
"fwmark", fwmark)
// apply the temporary fwmark to the wireguard interface
err = m.setFwMark(iface.Identifier, int(fwmark))
}
if table == 0 {
table = int(fwmark) // generate a new routing table base on interface index
slog.Debug("using routing table to handle default routes",
"interface", iface.Identifier,
"table", table)
}
return return
} }
func (m Manager) setFwMark(id domain.InterfaceIdentifier, fwmark int) error { slog.Debug("routes removed", "info", info.String())
err := m.wg.ConfigureDevice(string(id), wgtypes.Config{ }
FirewallMark: &fwmark,
}) func (m Manager) syncRoutes(ctx context.Context, info domain.RoutingTableInfo) error {
rc, ok := m.wgController.GetController(info.Interface).(RoutesController)
if !ok {
slog.Warn("no capable routes-controller found for interface", "interface", info.Interface.Identifier)
return nil
}
if !info.Interface.ManageRoutingTable() {
slog.Debug("interface does not manage routing table, skipping route update",
"interface", info.Interface.Identifier)
return nil
}
err := rc.SetRoutes(ctx, info)
if err != nil { if err != nil {
return fmt.Errorf("failed to update fwmark to: %d: %w", fwmark, err) return fmt.Errorf("failed to set routes for interface %s: %w", info.Interface.Identifier, err)
} }
return nil return nil
} }
func (m Manager) containsDefaultRoute(allowedIPs []domain.Cidr) (ipV4, ipV6 bool) { func (m Manager) removeRoutes(ctx context.Context, info domain.RoutingTableInfo) error {
for _, allowedIP := range allowedIPs { rc, ok := m.wgController.GetController(info.Interface).(RoutesController)
if ipV4 && ipV6 { if !ok {
break // speed up slog.Warn("no capable routes-controller found for interface", "interface", info.Interface.Identifier)
return nil
} }
if allowedIP.Prefix().Bits() == 0 { if !info.Interface.ManageRoutingTable() {
if allowedIP.IsV4() { slog.Debug("interface does not manage routing table, skipping route removal",
ipV4 = true "interface", info.Interface.Identifier)
} else { return nil
ipV6 = true
}
}
} }
return err := rc.RemoveRoutes(ctx, info)
if err != nil {
return fmt.Errorf("failed to remove routes for interface %s: %w", info.Interface.Identifier, err)
}
return nil
} }

View File

@ -1,7 +1,6 @@
package wireguard package wireguard
import ( import (
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"maps" "maps"
@ -12,33 +11,9 @@ import (
"github.com/h44z/wg-portal/internal/domain" "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 { type backendInstance struct {
Config config.BackendBase // Config is the configuration for the backend instance. Config config.BackendBase // Config is the configuration for the backend instance.
Implementation InterfaceController Implementation domain.InterfaceController
} }
type ControllerManager struct { type ControllerManager struct {
@ -118,11 +93,11 @@ func (c *ControllerManager) logRegisteredControllers() {
} }
} }
func (c *ControllerManager) GetControllerByName(backend domain.InterfaceBackend) InterfaceController { func (c *ControllerManager) GetControllerByName(backend domain.InterfaceBackend) domain.InterfaceController {
return c.getController(backend, "").Implementation return c.getController(backend, "").Implementation
} }
func (c *ControllerManager) GetController(iface domain.Interface) InterfaceController { func (c *ControllerManager) GetController(iface domain.Interface) domain.InterfaceController {
return c.getController(iface.Backend, iface.Identifier).Implementation return c.getController(iface.Backend, iface.Identifier).Implementation
} }

View File

@ -38,9 +38,9 @@ type InterfaceAndPeerDatabaseRepo interface {
} }
type WgQuickController interface { type WgQuickController interface {
ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error ExecuteInterfaceHook(ctx context.Context, id domain.InterfaceIdentifier, hookCmd string) error
SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error SetDNS(ctx context.Context, id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error
UnsetDNS(id domain.InterfaceIdentifier) error UnsetDNS(ctx context.Context, id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error
} }
type EventBus interface { type EventBus interface {
@ -57,7 +57,6 @@ type Manager struct {
bus EventBus bus EventBus
db InterfaceAndPeerDatabaseRepo db InterfaceAndPeerDatabaseRepo
wg *ControllerManager wg *ControllerManager
quick WgQuickController
userLockMap *sync.Map userLockMap *sync.Map
} }
@ -66,7 +65,6 @@ func NewWireGuardManager(
cfg *config.Config, cfg *config.Config,
bus EventBus, bus EventBus,
wg *ControllerManager, wg *ControllerManager,
quick WgQuickController,
db InterfaceAndPeerDatabaseRepo, db InterfaceAndPeerDatabaseRepo,
) (*Manager, error) { ) (*Manager, error) {
m := &Manager{ m := &Manager{
@ -74,7 +72,6 @@ func NewWireGuardManager(
bus: bus, bus: bus,
wg: wg, wg: wg,
db: db, db: db,
quick: quick,
userLockMap: &sync.Map{}, userLockMap: &sync.Map{},
} }

View File

@ -453,7 +453,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
return err return err
} }
existingInterface, err := m.db.GetInterface(ctx, id) existingInterface, existingPeers, err := m.db.GetInterfaceAndPeers(ctx, id)
if err != nil { if err != nil {
return fmt.Errorf("unable to find interface %s: %w", id, err) return fmt.Errorf("unable to find interface %s: %w", id, err)
} }
@ -462,21 +462,29 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
return fmt.Errorf("deletion not allowed: %w", err) return fmt.Errorf("deletion not allowed: %w", err)
} }
m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{
Interface: *existingInterface,
AllowedIps: existingInterface.GetAllowedIPs(existingPeers),
FwMark: existingInterface.FirewallMark,
Table: existingInterface.GetRoutingTable(),
TableStr: existingInterface.RoutingTable,
IsDeleted: true,
})
now := time.Now() now := time.Now()
existingInterface.Disabled = &now // simulate a disabled interface existingInterface.Disabled = &now // simulate a disabled interface
existingInterface.DisabledReason = domain.DisabledReasonDeleted existingInterface.DisabledReason = domain.DisabledReasonDeleted
physicalInterface, _ := m.wg.GetController(*existingInterface).GetInterface(ctx, id) if err := m.handleInterfacePreSaveHooks(ctx, existingInterface, !existingInterface.IsDisabled(),
false); err != nil {
if err := m.handleInterfacePreSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil {
return fmt.Errorf("pre-delete hooks failed: %w", err) return fmt.Errorf("pre-delete hooks failed: %w", err)
} }
if err := m.handleInterfacePreSaveActions(existingInterface); err != nil { if err := m.handleInterfacePreSaveActions(ctx, existingInterface); err != nil {
return fmt.Errorf("pre-delete actions failed: %w", err) return fmt.Errorf("pre-delete actions failed: %w", err)
} }
if err := m.deleteInterfacePeers(ctx, id); err != nil { if err := m.deleteInterfacePeers(ctx, existingInterface, existingPeers); err != nil {
return fmt.Errorf("peer deletion failure: %w", err) return fmt.Errorf("peer deletion failure: %w", err)
} }
@ -488,16 +496,12 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
return fmt.Errorf("deletion failure: %w", err) return fmt.Errorf("deletion failure: %w", err)
} }
fwMark := existingInterface.FirewallMark if err := m.handleInterfacePostSaveHooks(
if physicalInterface != nil && fwMark == 0 { ctx,
fwMark = physicalInterface.FirewallMark existingInterface,
} !existingInterface.IsDisabled(),
m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{ false,
FwMark: fwMark, ); err != nil {
Table: existingInterface.GetRoutingTable(),
})
if err := m.handleInterfacePostSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil {
return fmt.Errorf("post-delete hooks failed: %w", err) return fmt.Errorf("post-delete hooks failed: %w", err)
} }
@ -516,17 +520,21 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
return nil, fmt.Errorf("interface validation failed: %w", err) return nil, fmt.Errorf("interface validation failed: %w", err)
} }
oldEnabled, newEnabled := m.getInterfaceStateHistory(ctx, iface) oldEnabled, newEnabled, routeTableChanged := false, !iface.IsDisabled(), false // if the interface did not exist, we assume it was not enabled
oldInterface, err := m.db.GetInterface(ctx, iface.Identifier)
if err == nil {
oldEnabled, newEnabled, routeTableChanged = m.getInterfaceStateHistory(oldInterface, iface)
}
if err := m.handleInterfacePreSaveHooks(iface, oldEnabled, newEnabled); err != nil { if err := m.handleInterfacePreSaveHooks(ctx, iface, oldEnabled, newEnabled); err != nil {
return nil, fmt.Errorf("pre-save hooks failed: %w", err) return nil, fmt.Errorf("pre-save hooks failed: %w", err)
} }
if err := m.handleInterfacePreSaveActions(iface); err != nil { if err := m.handleInterfacePreSaveActions(ctx, iface); err != nil {
return nil, fmt.Errorf("pre-save actions failed: %w", err) return nil, fmt.Errorf("pre-save actions failed: %w", err)
} }
err := m.db.SaveInterface(ctx, iface.Identifier, func(i *domain.Interface) (*domain.Interface, error) { err = m.db.SaveInterface(ctx, iface.Identifier, func(i *domain.Interface) (*domain.Interface, error) {
iface.CopyCalculatedAttributes(i) iface.CopyCalculatedAttributes(i)
err := m.wg.GetController(*iface).SaveInterface(ctx, iface.Identifier, err := m.wg.GetController(*iface).SaveInterface(ctx, iface.Identifier,
@ -569,20 +577,35 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
} }
if iface.IsDisabled() { if iface.IsDisabled() {
physicalInterface, _ := m.wg.GetController(*iface).GetInterface(ctx, iface.Identifier)
fwMark := iface.FirewallMark
if physicalInterface != nil && fwMark == 0 {
fwMark = physicalInterface.FirewallMark
}
m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{ m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{
FwMark: fwMark, Interface: *iface,
AllowedIps: iface.GetAllowedIPs(peers),
FwMark: iface.FirewallMark,
Table: iface.GetRoutingTable(), Table: iface.GetRoutingTable(),
TableStr: iface.RoutingTable,
}) })
} else { } else {
m.bus.Publish(app.TopicRouteUpdate, "interface updated: "+string(iface.Identifier)) m.bus.Publish(app.TopicRouteUpdate, domain.RoutingTableInfo{
Interface: *iface,
AllowedIps: iface.GetAllowedIPs(peers),
FwMark: iface.FirewallMark,
Table: iface.GetRoutingTable(),
TableStr: iface.RoutingTable,
})
// if the route table changed, ensure that the old entries are remove
if routeTableChanged {
m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{
Interface: *oldInterface,
AllowedIps: oldInterface.GetAllowedIPs(peers),
FwMark: oldInterface.FirewallMark,
Table: oldInterface.GetRoutingTable(),
TableStr: oldInterface.RoutingTable,
IsDeleted: true, // mark the old entries as deleted
})
}
} }
if err := m.handleInterfacePostSaveHooks(iface, oldEnabled, newEnabled); err != nil { if err := m.handleInterfacePostSaveHooks(ctx, iface, oldEnabled, newEnabled); err != nil {
return nil, fmt.Errorf("post-save hooks failed: %w", err) return nil, fmt.Errorf("post-save hooks failed: %w", err)
} }
@ -618,60 +641,90 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
return iface, nil return iface, nil
} }
func (m Manager) getInterfaceStateHistory(ctx context.Context, iface *domain.Interface) (oldEnabled, newEnabled bool) { func (m Manager) getInterfaceStateHistory(
oldInterface, err := m.db.GetInterface(ctx, iface.Identifier) oldInterface *domain.Interface,
if err != nil { iface *domain.Interface,
return false, !iface.IsDisabled() // if the interface did not exist, we assume it was not enabled ) (oldEnabled, newEnabled, routeTableChanged bool) {
return !oldInterface.IsDisabled(), !iface.IsDisabled(), oldInterface.RoutingTable != iface.RoutingTable
} }
return !oldInterface.IsDisabled(), !iface.IsDisabled() func (m Manager) handleInterfacePreSaveActions(ctx context.Context, iface *domain.Interface) error {
wgQuickController, ok := m.wg.GetController(*iface).(WgQuickController)
if !ok {
slog.Warn("failed to perform pre-save actions", "interface", iface.Identifier,
"error", "no capable controller found")
return nil
} }
func (m Manager) handleInterfacePreSaveActions(iface *domain.Interface) error { // update DNS settings only for client interfaces
if iface.Type == domain.InterfaceTypeClient || iface.Type == domain.InterfaceTypeAny {
if !iface.IsDisabled() { if !iface.IsDisabled() {
if err := m.quick.SetDNS(iface.Identifier, iface.DnsStr, iface.DnsSearchStr); err != nil { if err := wgQuickController.SetDNS(ctx, iface.Identifier, iface.DnsStr, iface.DnsSearchStr); err != nil {
return fmt.Errorf("failed to update dns settings: %w", err) return fmt.Errorf("failed to update dns settings: %w", err)
} }
} else { } else {
if err := m.quick.UnsetDNS(iface.Identifier); err != nil { if err := wgQuickController.UnsetDNS(ctx, iface.Identifier, iface.DnsStr, iface.DnsSearchStr); err != nil {
return fmt.Errorf("failed to clear dns settings: %w", err) return fmt.Errorf("failed to clear dns settings: %w", err)
} }
} }
}
return nil return nil
} }
func (m Manager) handleInterfacePreSaveHooks(iface *domain.Interface, oldEnabled, newEnabled bool) error { func (m Manager) handleInterfacePreSaveHooks(
ctx context.Context,
iface *domain.Interface,
oldEnabled, newEnabled bool,
) error {
if oldEnabled == newEnabled { if oldEnabled == newEnabled {
return nil // do nothing if state did not change return nil // do nothing if state did not change
} }
slog.Debug("executing pre-save hooks", "interface", iface.Identifier, "up", newEnabled) slog.Debug("executing pre-save hooks", "interface", iface.Identifier, "up", newEnabled)
wgQuickController, ok := m.wg.GetController(*iface).(WgQuickController)
if !ok {
slog.Warn("failed to execute pre-save hooks", "interface", iface.Identifier, "up", newEnabled,
"error", "no capable controller found")
return nil
}
if newEnabled { if newEnabled {
if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PreUp); err != nil { if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PreUp); err != nil {
return fmt.Errorf("failed to execute pre-up hook: %w", err) return fmt.Errorf("failed to execute pre-up hook: %w", err)
} }
} else { } else {
if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PreDown); err != nil { if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PreDown); err != nil {
return fmt.Errorf("failed to execute pre-down hook: %w", err) return fmt.Errorf("failed to execute pre-down hook: %w", err)
} }
} }
return nil return nil
} }
func (m Manager) handleInterfacePostSaveHooks(iface *domain.Interface, oldEnabled, newEnabled bool) error { func (m Manager) handleInterfacePostSaveHooks(
ctx context.Context,
iface *domain.Interface,
oldEnabled, newEnabled bool,
) error {
if oldEnabled == newEnabled { if oldEnabled == newEnabled {
return nil // do nothing if state did not change return nil // do nothing if state did not change
} }
slog.Debug("executing post-save hooks", "interface", iface.Identifier, "up", newEnabled) slog.Debug("executing post-save hooks", "interface", iface.Identifier, "up", newEnabled)
wgQuickController, ok := m.wg.GetController(*iface).(WgQuickController)
if !ok {
slog.Warn("failed to execute post-save hooks", "interface", iface.Identifier, "up", newEnabled,
"error", "no capable controller found")
return nil
}
if newEnabled { if newEnabled {
if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PostUp); err != nil { if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PostUp); err != nil {
return fmt.Errorf("failed to execute post-up hook: %w", err) return fmt.Errorf("failed to execute post-up hook: %w", err)
} }
} else { } else {
if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PostDown); err != nil { if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PostDown); err != nil {
return fmt.Errorf("failed to execute post-down hook: %w", err) return fmt.Errorf("failed to execute post-down hook: %w", err)
} }
} }
@ -799,7 +852,7 @@ func (m Manager) getFreshListenPort(ctx context.Context) (port int, err error) {
func (m Manager) importInterface( func (m Manager) importInterface(
ctx context.Context, ctx context.Context,
backend InterfaceController, backend domain.InterfaceController,
in *domain.PhysicalInterface, in *domain.PhysicalInterface,
peers []domain.PhysicalPeer, peers []domain.PhysicalPeer,
) error { ) error {
@ -901,13 +954,9 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain
return nil return nil
} }
func (m Manager) deleteInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) error { func (m Manager) deleteInterfacePeers(ctx context.Context, iface *domain.Interface, allPeers []domain.Peer) error {
iface, allPeers, err := m.db.GetInterfaceAndPeers(ctx, id)
if err != nil {
return err
}
for _, peer := range allPeers { for _, peer := range allPeers {
err = m.wg.GetController(*iface).DeletePeer(ctx, id, peer.Identifier) err := m.wg.GetController(*iface).DeletePeer(ctx, iface.Identifier, peer.Identifier)
if err != nil && !errors.Is(err, os.ErrNotExist) { if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("wireguard peer deletion failure for %s: %w", peer.Identifier, err) return fmt.Errorf("wireguard peer deletion failure for %s: %w", peer.Identifier, err)
} }

View File

@ -388,9 +388,20 @@ func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
return fmt.Errorf("failed to delete peer %s: %w", id, err) return fmt.Errorf("failed to delete peer %s: %w", id, err)
} }
peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
if err != nil {
return fmt.Errorf("failed to load peers for interface %s: %w", iface.Identifier, err)
}
m.bus.Publish(app.TopicPeerDeleted, *peer) m.bus.Publish(app.TopicPeerDeleted, *peer)
// Update routes after peers have changed // Update routes after peers have changed
m.bus.Publish(app.TopicRouteUpdate, "peers updated") m.bus.Publish(app.TopicRouteUpdate, domain.RoutingTableInfo{
Interface: *iface,
AllowedIps: iface.GetAllowedIPs(peers),
FwMark: iface.FirewallMark,
Table: iface.GetRoutingTable(),
TableStr: iface.RoutingTable,
})
// Update interface after peers have changed // Update interface after peers have changed
m.bus.Publish(app.TopicPeerInterfaceUpdated, peer.InterfaceIdentifier) m.bus.Publish(app.TopicPeerInterfaceUpdated, peer.InterfaceIdentifier)
@ -438,20 +449,26 @@ func (m Manager) GetUserPeerStats(ctx context.Context, id domain.UserIdentifier)
// region helper-functions // region helper-functions
func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error { func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error {
interfaces := make(map[domain.InterfaceIdentifier]struct{}) interfaces := make(map[domain.InterfaceIdentifier]domain.Interface)
for _, peer := range peers { for _, peer := range peers {
// get interface from db if it is not yet in the map
if _, ok := interfaces[peer.InterfaceIdentifier]; !ok {
iface, err := m.db.GetInterface(ctx, peer.InterfaceIdentifier) iface, err := m.db.GetInterface(ctx, peer.InterfaceIdentifier)
if err != nil { if err != nil {
return fmt.Errorf("unable to find interface %s: %w", peer.InterfaceIdentifier, err) return fmt.Errorf("unable to find interface %s: %w", peer.InterfaceIdentifier, err)
} }
interfaces[peer.InterfaceIdentifier] = *iface
}
iface := interfaces[peer.InterfaceIdentifier]
// Always save the peer to the backend, regardless of disabled/expired state // Always save the peer to the backend, regardless of disabled/expired state
// The backend will handle the disabled state appropriately // The backend will handle the disabled state appropriately
err = m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) { err := m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) {
peer.CopyCalculatedAttributes(p) peer.CopyCalculatedAttributes(p)
err := m.wg.GetController(*iface).SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier, err := m.wg.GetController(iface).SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier,
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) { func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
domain.MergeToPhysicalPeer(pp, peer) domain.MergeToPhysicalPeer(pp, peer)
return pp, nil return pp, nil
@ -475,13 +492,22 @@ func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error {
Peer: *peer, Peer: *peer,
}, },
}) })
interfaces[peer.InterfaceIdentifier] = struct{}{}
} }
// Update routes after peers have changed // Update routes after peers have changed
if len(interfaces) != 0 { for id, iface := range interfaces {
m.bus.Publish(app.TopicRouteUpdate, "peers updated") interfacePeers, err := m.db.GetInterfacePeers(ctx, id)
if err != nil {
return fmt.Errorf("failed to re-load peers for interface %s: %w", id, err)
}
m.bus.Publish(app.TopicRouteUpdate, domain.RoutingTableInfo{
Interface: iface,
AllowedIps: iface.GetAllowedIPs(interfacePeers),
FwMark: iface.FirewallMark,
Table: iface.GetRoutingTable(),
TableStr: iface.RoutingTable,
})
} }
for iface := range interfaces { for iface := range interfaces {

View File

@ -211,6 +211,10 @@ type OpenIDConnectProvider struct {
// If LogUserInfo is set to true, the user info retrieved from the OIDC provider will be logged in trace level. // If LogUserInfo is set to true, the user info retrieved from the OIDC provider will be logged in trace level.
LogUserInfo bool `yaml:"log_user_info"` LogUserInfo bool `yaml:"log_user_info"`
// If LogSensitiveInfo is set to true, sensitive information retrieved from the OIDC provider will be logged in trace level.
// This also includes OAuth tokens! Keep this disabled in production!
LogSensitiveInfo bool `yaml:"log_sensitive_info"`
} }
// OAuthProvider contains the configuration for the OAuth provider. // OAuthProvider contains the configuration for the OAuth provider.
@ -252,6 +256,10 @@ type OAuthProvider struct {
// If LogUserInfo is set to true, the user info retrieved from the OAuth provider will be logged in trace level. // 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"` LogUserInfo bool `yaml:"log_user_info"`
// If LogSensitiveInfo is set to true, sensitive information retrieved from the OAuth provider will be logged in trace level.
// This also includes OAuth tokens! Keep this disabled in production!
LogSensitiveInfo bool `yaml:"log_sensitive_info"`
} }
// WebauthnConfig contains the configuration for the WebAuthn authenticator. // WebauthnConfig contains the configuration for the WebAuthn authenticator.

View File

@ -13,6 +13,7 @@ type Backend struct {
// Local Backend-specific configuration // 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") IgnoredLocalInterfaces []string `yaml:"ignored_local_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0")
LocalResolvconfPrefix string `yaml:"local_resolvconf_prefix"` // The prefix to use for interface names when passing them to resolvconf.
// External Backend-specific configuration // External Backend-specific configuration

View File

@ -134,6 +134,9 @@ func defaultConfig() *Config {
cfg.Backend = Backend{ cfg.Backend = Backend{
Default: LocalBackendName, // local backend is the default (using wgcrtl) Default: LocalBackendName, // local backend is the default (using wgcrtl)
// Most resolconf implementations use "tun." as a prefix for interface names.
// But systemd's implementation uses no prefix, for example.
LocalResolvconfPrefix: "tun.",
} }
cfg.Web = WebConfig{ cfg.Web = WebConfig{

View File

@ -41,4 +41,6 @@ type MailConfig struct {
From string `yaml:"from"` From string `yaml:"from"`
// LinkOnly specifies whether emails should only contain a link to WireGuard Portal or attach the full configuration // LinkOnly specifies whether emails should only contain a link to WireGuard Portal or attach the full configuration
LinkOnly bool `yaml:"link_only"` LinkOnly bool `yaml:"link_only"`
// AllowPeerEmail specifies whether emails should be sent to peers which have no valid user account linked, but an email address is set as "user".
AllowPeerEmail bool `yaml:"allow_peer_email"`
} }

View File

@ -13,6 +13,7 @@ import (
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"github.com/h44z/wg-portal/internal" "github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/config"
) )
const ( const (
@ -132,9 +133,14 @@ func (i *Interface) GetConfigFileName() string {
return filename return filename
} }
// GetAllowedIPs returns the allowed IPs for the interface depending on the interface type and peers.
// For example, if the interface type is Server, the allowed IPs are the IPs of the peers.
// If the interface type is Client, the allowed IPs correspond to the AllowedIPsStr of the peers.
func (i *Interface) GetAllowedIPs(peers []Peer) []Cidr { func (i *Interface) GetAllowedIPs(peers []Peer) []Cidr {
var allowedCidrs []Cidr var allowedCidrs []Cidr
switch i.Type {
case InterfaceTypeServer, InterfaceTypeAny:
for _, peer := range peers { for _, peer := range peers {
for _, ip := range peer.Interface.Addresses { for _, ip := range peer.Interface.Addresses {
allowedCidrs = append(allowedCidrs, ip.HostAddr()) allowedCidrs = append(allowedCidrs, ip.HostAddr())
@ -146,6 +152,14 @@ func (i *Interface) GetAllowedIPs(peers []Peer) []Cidr {
} }
} }
} }
case InterfaceTypeClient:
for _, peer := range peers {
allowedIPs, err := CidrsFromString(peer.AllowedIPsStr.GetValue())
if err == nil {
allowedCidrs = append(allowedCidrs, allowedIPs...)
}
}
}
return allowedCidrs return allowedCidrs
} }
@ -159,6 +173,7 @@ func (i *Interface) ManageRoutingTable() bool {
// //
// -1 if RoutingTable was set to "off" or an error occurred // -1 if RoutingTable was set to "off" or an error occurred
func (i *Interface) GetRoutingTable() int { func (i *Interface) GetRoutingTable() int {
routingTableStr := strings.ToLower(i.RoutingTable) routingTableStr := strings.ToLower(i.RoutingTable)
switch { switch {
case routingTableStr == "": case routingTableStr == "":
@ -166,6 +181,9 @@ func (i *Interface) GetRoutingTable() int {
case routingTableStr == "off": case routingTableStr == "off":
return -1 return -1
case strings.HasPrefix(routingTableStr, "0x"): case strings.HasPrefix(routingTableStr, "0x"):
if i.Backend != config.LocalBackendName {
return 0 // ignore numeric routing table numbers for non-local controllers
}
numberStr := strings.ReplaceAll(routingTableStr, "0x", "") numberStr := strings.ReplaceAll(routingTableStr, "0x", "")
routingTable, err := strconv.ParseUint(numberStr, 16, 64) routingTable, err := strconv.ParseUint(numberStr, 16, 64)
if err != nil { if err != nil {
@ -178,6 +196,9 @@ func (i *Interface) GetRoutingTable() int {
} }
return int(routingTable) return int(routingTable)
default: default:
if i.Backend != config.LocalBackendName {
return 0 // ignore numeric routing table numbers for non-local controllers
}
routingTable, err := strconv.Atoi(routingTableStr) routingTable, err := strconv.Atoi(routingTableStr)
if err != nil { if err != nil {
slog.Error("failed to parse routing table number", "table", routingTableStr, "error", err) slog.Error("failed to parse routing table number", "table", routingTableStr, "error", err)
@ -308,12 +329,18 @@ func MergeToPhysicalInterface(pi *PhysicalInterface, i *Interface) {
} }
type RoutingTableInfo struct { type RoutingTableInfo struct {
Interface Interface
AllowedIps []Cidr
FwMark uint32 FwMark uint32
Table int Table int
TableStr string // the routing table number as string (used by mikrotik, linux uses the numeric value)
IsDeleted bool // true if the interface was deleted, false otherwise
} }
func (r RoutingTableInfo) String() string { func (r RoutingTableInfo) String() string {
return fmt.Sprintf("%d -> %d", r.FwMark, r.Table) v4, v6 := CidrsPerFamily(r.AllowedIps)
return fmt.Sprintf("%s: fwmark=%d; table=%d; routes_4=%d; routes_6=%d", r.Interface.Identifier, r.FwMark, r.Table,
len(v4), len(v6))
} }
func (r RoutingTableInfo) ManagementEnabled() bool { func (r RoutingTableInfo) ManagementEnabled() bool {

View File

@ -0,0 +1,27 @@
package domain
import "context"
type InterfaceController interface {
GetId() InterfaceBackend
GetInterfaces(_ context.Context) ([]PhysicalInterface, error)
GetInterface(_ context.Context, id InterfaceIdentifier) (*PhysicalInterface, error)
GetPeers(_ context.Context, deviceId InterfaceIdentifier) ([]PhysicalPeer, error)
SaveInterface(
_ context.Context,
id InterfaceIdentifier,
updateFunc func(pi *PhysicalInterface) (*PhysicalInterface, error),
) error
DeleteInterface(_ context.Context, id InterfaceIdentifier) error
SavePeer(
_ context.Context,
deviceId InterfaceIdentifier,
id PeerIdentifier,
updateFunc func(pp *PhysicalPeer) (*PhysicalPeer, error),
) error
DeletePeer(_ context.Context, deviceId InterfaceIdentifier, id PeerIdentifier) error
PingAddresses(
ctx context.Context,
addr string,
) (*PingerResult, error)
}

View File

@ -5,6 +5,8 @@ import (
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/h44z/wg-portal/internal/config"
) )
func TestInterface_IsDisabledReturnsTrueWhenDisabled(t *testing.T) { func TestInterface_IsDisabledReturnsTrueWhenDisabled(t *testing.T) {
@ -37,8 +39,9 @@ func TestInterface_GetConfigFileNameReturnsCorrectFileName(t *testing.T) {
assert.Equal(t, expected, iface.GetConfigFileName()) assert.Equal(t, expected, iface.GetConfigFileName())
} }
func TestInterface_GetAllowedIPsReturnsCorrectCidrs(t *testing.T) { func TestInterface_GetAllowedIPsReturnsCorrectCidrsServerMode(t *testing.T) {
peer1 := Peer{ peer1 := Peer{
AllowedIPsStr: ConfigOption[string]{Value: "192.168.2.2/32"},
Interface: PeerInterfaceConfig{ Interface: PeerInterfaceConfig{
Addresses: []Cidr{ Addresses: []Cidr{
{Cidr: "192.168.1.2/32", Addr: "192.168.1.2", NetLength: 32}, {Cidr: "192.168.1.2/32", Addr: "192.168.1.2", NetLength: 32},
@ -46,16 +49,45 @@ func TestInterface_GetAllowedIPsReturnsCorrectCidrs(t *testing.T) {
}, },
} }
peer2 := Peer{ peer2 := Peer{
AllowedIPsStr: ConfigOption[string]{Value: "10.0.2.2/32"},
ExtraAllowedIPsStr: "10.20.2.2/32",
Interface: PeerInterfaceConfig{ Interface: PeerInterfaceConfig{
Addresses: []Cidr{ Addresses: []Cidr{
{Cidr: "10.0.0.2/32", Addr: "10.0.0.2", NetLength: 32}, {Cidr: "10.0.0.2/32", Addr: "10.0.0.2", NetLength: 32},
}, },
}, },
} }
iface := &Interface{} iface := &Interface{Type: InterfaceTypeServer}
expected := []Cidr{ expected := []Cidr{
{Cidr: "192.168.1.2/32", Addr: "192.168.1.2", NetLength: 32}, {Cidr: "192.168.1.2/32", Addr: "192.168.1.2", NetLength: 32},
{Cidr: "10.0.0.2/32", Addr: "10.0.0.2", NetLength: 32}, {Cidr: "10.0.0.2/32", Addr: "10.0.0.2", NetLength: 32},
{Cidr: "10.20.2.2/32", Addr: "10.20.2.2", NetLength: 32},
}
assert.Equal(t, expected, iface.GetAllowedIPs([]Peer{peer1, peer2}))
}
func TestInterface_GetAllowedIPsReturnsCorrectCidrsClientMode(t *testing.T) {
peer1 := Peer{
AllowedIPsStr: ConfigOption[string]{Value: "192.168.2.2/32"},
Interface: PeerInterfaceConfig{
Addresses: []Cidr{
{Cidr: "192.168.1.2/32", Addr: "192.168.1.2", NetLength: 32},
},
},
}
peer2 := Peer{
AllowedIPsStr: ConfigOption[string]{Value: "10.0.2.2/32"},
ExtraAllowedIPsStr: "10.20.2.2/32",
Interface: PeerInterfaceConfig{
Addresses: []Cidr{
{Cidr: "10.0.0.2/32", Addr: "10.0.0.2", NetLength: 32},
},
},
}
iface := &Interface{Type: InterfaceTypeClient}
expected := []Cidr{
{Cidr: "192.168.2.2/32", Addr: "192.168.2.2", NetLength: 32},
{Cidr: "10.0.2.2/32", Addr: "10.0.2.2", NetLength: 32},
} }
assert.Equal(t, expected, iface.GetAllowedIPs([]Peer{peer1, peer2})) assert.Equal(t, expected, iface.GetAllowedIPs([]Peer{peer1, peer2}))
} }
@ -66,10 +98,22 @@ func TestInterface_ManageRoutingTableReturnsCorrectValue(t *testing.T) {
iface.RoutingTable = "100" iface.RoutingTable = "100"
assert.True(t, iface.ManageRoutingTable()) assert.True(t, iface.ManageRoutingTable())
iface = &Interface{RoutingTable: "off", Backend: config.LocalBackendName}
assert.False(t, iface.ManageRoutingTable())
iface.RoutingTable = "100"
assert.True(t, iface.ManageRoutingTable())
iface = &Interface{RoutingTable: "off", Backend: "mikrotik-xxx"}
assert.False(t, iface.ManageRoutingTable())
iface.RoutingTable = "100"
assert.True(t, iface.ManageRoutingTable())
} }
func TestInterface_GetRoutingTableReturnsCorrectValue(t *testing.T) { func TestInterface_GetRoutingTableReturnsCorrectValue(t *testing.T) {
iface := &Interface{RoutingTable: ""} iface := &Interface{RoutingTable: "", Backend: config.LocalBackendName}
assert.Equal(t, 0, iface.GetRoutingTable()) assert.Equal(t, 0, iface.GetRoutingTable())
iface.RoutingTable = "off" iface.RoutingTable = "off"
@ -81,3 +125,17 @@ func TestInterface_GetRoutingTableReturnsCorrectValue(t *testing.T) {
iface.RoutingTable = "200" iface.RoutingTable = "200"
assert.Equal(t, 200, iface.GetRoutingTable()) assert.Equal(t, 200, iface.GetRoutingTable())
} }
func TestInterface_GetRoutingTableNonLocal(t *testing.T) {
iface := &Interface{RoutingTable: "off", Backend: "something different"}
assert.Equal(t, -1, iface.GetRoutingTable())
iface.RoutingTable = "0"
assert.Equal(t, 0, iface.GetRoutingTable())
iface.RoutingTable = "100"
assert.Equal(t, 0, iface.GetRoutingTable())
iface.RoutingTable = "abc"
assert.Equal(t, 0, iface.GetRoutingTable())
}

View File

@ -26,6 +26,10 @@ func (c Cidr) IsValid() bool {
return c.Prefix().IsValid() return c.Prefix().IsValid()
} }
func (c Cidr) EqualPrefix(other Cidr) bool {
return c.Addr == other.Addr && c.NetLength == other.NetLength
}
func CidrFromString(str string) (Cidr, error) { func CidrFromString(str string) (Cidr, error) {
prefix, err := netip.ParsePrefix(strings.TrimSpace(str)) prefix, err := netip.ParsePrefix(strings.TrimSpace(str))
if err != nil { if err != nil {
@ -199,3 +203,26 @@ func (c Cidr) Contains(other Cidr) bool {
return subnet.Contains(otherIP) return subnet.Contains(otherIP)
} }
// ContainsDefaultRoute returns true if the given CIDRs contain a default route.
func ContainsDefaultRoute(cidrs []Cidr) bool {
for _, allowedIP := range cidrs {
if allowedIP.Prefix().Bits() == 0 {
return true
}
}
return false
}
// CidrsPerFamily returns a slice of CIDRs, one for each family (IPv4 and IPv6).
func CidrsPerFamily(cidrs []Cidr) (ipv4, ipv6 []Cidr) {
for _, cidr := range cidrs {
if cidr.IsV4() {
ipv4 = append(ipv4, cidr)
} else {
ipv6 = append(ipv6, cidr)
}
}
return
}

View File

@ -308,22 +308,33 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) { func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
pp.Identifier = p.Identifier pp.Identifier = p.Identifier
pp.Endpoint = p.Endpoint.GetValue() pp.PresharedKey = p.PresharedKey
if p.Interface.Type == InterfaceTypeServer { pp.PublicKey = p.Interface.PublicKey
switch p.Interface.Type {
case InterfaceTypeClient: // this means that the corresponding interface in wgportal is a server interface
allowedIPs := make([]Cidr, len(p.Interface.Addresses))
for i, ip := range p.Interface.Addresses {
allowedIPs[i] = ip.HostAddr() // add the peer's host address to the allowed IPs
}
extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr)
pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...)
case InterfaceTypeServer: // this means that the corresponding interface in wgportal is a client interface
allowedIPs, _ := CidrsFromString(p.AllowedIPsStr.GetValue()) allowedIPs, _ := CidrsFromString(p.AllowedIPsStr.GetValue())
extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr) extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr)
pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...) pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...)
} else { pp.Endpoint = p.Endpoint.GetValue()
pp.PersistentKeepalive = p.PersistentKeepalive.GetValue()
case InterfaceTypeAny: // this means that the corresponding interface in wgportal has no specific type
allowedIPs := make([]Cidr, len(p.Interface.Addresses)) allowedIPs := make([]Cidr, len(p.Interface.Addresses))
for i, ip := range p.Interface.Addresses { for i, ip := range p.Interface.Addresses {
allowedIPs[i] = ip.HostAddr() allowedIPs[i] = ip.HostAddr() // add the peer's host address to the allowed IPs
} }
extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr) extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr)
pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...) pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...)
} pp.Endpoint = p.Endpoint.GetValue()
pp.PresharedKey = p.PresharedKey
pp.PublicKey = p.Interface.PublicKey
pp.PersistentKeepalive = p.PersistentKeepalive.GetValue() pp.PersistentKeepalive = p.PersistentKeepalive.GetValue()
}
switch pp.ImportSource { switch pp.ImportSource {
case ControllerTypeMikrotik: case ControllerTypeMikrotik:

View File

@ -267,6 +267,7 @@ func parseHttpResponse[T any](resp *http.Response, err error) MikrotikApiRespons
} }
defer func(Body io.ReadCloser) { defer func(Body io.ReadCloser) {
_, _ = io.Copy(io.Discard, Body) // ensure to empty the body
err := Body.Close() err := Body.Close()
if err != nil { if err != nil {
slog.Error("failed to close response body", "error", err) slog.Error("failed to close response body", "error", err)

View File

@ -6,8 +6,12 @@ repo_name: h44z/wg-portal
repo_url: https://github.com/h44z/wg-portal repo_url: https://github.com/h44z/wg-portal
copyright: Copyright &copy; 2023-2025 WireGuard Portal Project copyright: Copyright &copy; 2023-2025 WireGuard Portal Project
extra_javascript:
- javascript/img-comparison-slider.js
extra_css: extra_css:
- stylesheets/extra.css - stylesheets/extra.css
- stylesheets/img-comparison-slider.css
theme: theme:
name: material name: material