mirror of https://github.com/h44z/wg-portal.git
Compare commits
24 Commits
v2.1.0-bet
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
da76327569 | |
|
|
c154cb3977 | |
|
|
7bca35728d | |
|
|
3d923b328e | |
|
|
139fb17f98 | |
|
|
faf1d995a8 | |
|
|
f53d0b3d7f | |
|
|
cdf3a49801 | |
|
|
298c9405f6 | |
|
|
c7724b620a | |
|
|
4d19f1d8bb | |
|
|
3f539a1615 | |
|
|
0305911467 | |
|
|
85f7a5a9a6 | |
|
|
fb509a39b8 | |
|
|
9e6ad98c4e | |
|
|
05fbcccc9c | |
|
|
97b6c398e8 | |
|
|
cc2d1f53c4 | |
|
|
b122e1ae60 | |
|
|
ea26e56994 | |
|
|
61bf349813 | |
|
|
80693400be | |
|
|
afb38b685c |
|
|
@ -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]
|
||||
|
|
@ -52,7 +52,7 @@ COPY --from=builder /build/dist/wg-portal /
|
|||
######
|
||||
FROM alpine:3.22
|
||||
# Install OS-level dependencies
|
||||
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools
|
||||
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools tzdata
|
||||
# Setup timezone
|
||||
ENV TZ=UTC
|
||||
# Copy binaries
|
||||
|
|
|
|||
|
|
@ -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
|
||||
a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -21,7 +21,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos
|
|||
## Features
|
||||
|
||||
* 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
|
||||
* QR-Code for convenient mobile client configuration
|
||||
* 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
|
||||
* Can be used with existing WireGuard setups
|
||||
* Support for multiple WireGuard interfaces
|
||||
* Supports multiple WireGuard backends (wgctrl or MikroTik [BETA])
|
||||
* Supports multiple WireGuard backends (wgctrl or MikroTik)
|
||||
* Peer Expiry Feature
|
||||
* Handles route and DNS settings like wg-quick does
|
||||
* 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>
|
||||
|
||||
## Contributors and Sponsors
|
||||
|
||||
Thanks so much for all your contributions! They’re 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]
|
||||
> 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).
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ If you believe you've found a security issue in one of the supported versions of
|
|||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| v2.x | :white_check_mark: |
|
||||
| v1.x | :white_check_mark: |
|
||||
| v1.x | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
|
|
|||
|
|
@ -53,8 +53,6 @@ func main() {
|
|||
wireGuard, err := wireguard.NewControllerManager(cfg)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
wgQuick := adapters.NewWgQuickRepo()
|
||||
|
||||
mailer := adapters.NewSmtpMailRepo(cfg.Mail)
|
||||
|
||||
metricsServer := adapters.NewMetricsServer(cfg)
|
||||
|
|
@ -93,7 +91,7 @@ func main() {
|
|||
webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database)
|
||||
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, database)
|
||||
internal.AssertNoError(err)
|
||||
wireGuardManager.StartBackgroundJobs(ctx)
|
||||
|
||||
|
|
@ -107,7 +105,7 @@ func main() {
|
|||
mailManager, err := mail.NewMailManager(cfg, mailer, cfgFileManager, database, database)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
routeManager, err := route.NewRouteManager(cfg, eventBus, database)
|
||||
routeManager, err := route.NewRouteManager(cfg, eventBus, database, wireGuard)
|
||||
internal.AssertNoError(err)
|
||||
routeManager.StartBackgroundJobs(ctx)
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
|
|
@ -15,6 +15,9 @@ backend:
|
|||
# default backend decides where new interfaces are created
|
||||
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:
|
||||
- id: mikrotik # unique id, not "local"
|
||||
display_name: RouterOS RB5009 # optional nice name
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ core:
|
|||
|
||||
backend:
|
||||
default: local
|
||||
local_resolvconf_prefix: tun.
|
||||
|
||||
advanced:
|
||||
log_level: info
|
||||
|
|
@ -72,6 +73,7 @@ mail:
|
|||
auth_type: plain
|
||||
from: Wireguard Portal <noreply@wireguard.local>
|
||||
link_only: false
|
||||
allow_peer_email: false
|
||||
|
||||
auth:
|
||||
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.
|
||||
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`
|
||||
- **Default:** *(empty)*
|
||||
- **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
|
||||
|
||||
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`
|
||||
- **Default:** `127.0.0.1`
|
||||
|
|
@ -418,6 +427,12 @@ Options for configuring email notifications or sending peer configurations via e
|
|||
- **Default:** `false`
|
||||
- **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
|
||||
|
|
@ -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.
|
||||
|
||||
#### `registration_enabled`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, a new user will be created in WireGuard Portal if not already present.
|
||||
|
||||
#### `log_user_info`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **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
|
||||
|
|
@ -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.
|
||||
|
||||
#### `registration_enabled`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, new users are created automatically on successful login.
|
||||
|
||||
#### `log_user_info`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **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
|
||||
|
|
@ -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`).
|
||||
|
||||
#### `start_tls`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, use STARTTLS to secure the LDAP connection.
|
||||
|
||||
#### `cert_validation`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, validate the LDAP server’s TLS certificate.
|
||||
|
||||
#### `tls_certificate_path`
|
||||
|
|
@ -667,19 +692,19 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
|
|||
```
|
||||
|
||||
#### `disable_missing`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal.
|
||||
|
||||
#### `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.
|
||||
|
||||
#### `registration_enabled`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login.
|
||||
|
||||
#### `log_user_info`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
}
|
||||
.tx-hero__image {
|
||||
max-width: 1000px;
|
||||
min-width: 600px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
|
|
@ -218,7 +218,7 @@
|
|||
|
||||
.secondary-section .g .section .component-wrapper .responsive-grid .card {
|
||||
position: relative;
|
||||
background-color: #fff none repeat scroll 0% 0%;
|
||||
background-color: #fff;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
@ -300,6 +300,59 @@
|
|||
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>
|
||||
|
||||
<!-- Hero for landing page -->
|
||||
|
|
@ -310,7 +363,6 @@
|
|||
<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
|
||||
WireGuard VPN connections. It's built on top of WireGuard's official <span class="em">wgctrl</span> library.</p>
|
||||
</p>
|
||||
<a
|
||||
href="documentation/overview/"
|
||||
title="Get Started"
|
||||
|
|
@ -326,11 +378,34 @@
|
|||
|
||||
<div class="md-container">
|
||||
<div class="tx-hero__image">
|
||||
<img
|
||||
src="{{config.site_url}}/assets/images/screenshot.png"
|
||||
alt=""
|
||||
draggable="false"
|
||||
>
|
||||
<div>
|
||||
<img-comparison-slider hover="hover">
|
||||
<figure slot="first" class="before">
|
||||
<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>
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -30,6 +30,6 @@
|
|||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"sass-embedded": "^1.86.3",
|
||||
"vite": "6.3.4"
|
||||
"vite": "^6.3.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup>
|
||||
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 { securityStore } from "./stores/security";
|
||||
import { settingsStore } from "@/stores/settings";
|
||||
|
|
@ -11,12 +11,13 @@ const auth = authStore()
|
|||
const sec = securityStore()
|
||||
const settings = settingsStore()
|
||||
|
||||
const currentTheme = ref("auto")
|
||||
|
||||
onMounted(async () => {
|
||||
console.log("Starting WireGuard Portal frontend...");
|
||||
|
||||
// restore theme from localStorage
|
||||
const theme = localStorage.getItem('wgTheme') || 'light';
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
switchTheme(getTheme());
|
||||
|
||||
await sec.LoadSecurityProperties();
|
||||
await auth.LoadProviders();
|
||||
|
|
@ -44,10 +45,22 @@ const switchLanguage = function (lang) {
|
|||
}
|
||||
}
|
||||
|
||||
const getTheme = function () {
|
||||
return localStorage.getItem('wgTheme') || 'auto';
|
||||
}
|
||||
|
||||
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);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', bsTheme);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +76,7 @@ const languageFlag = computed(() => {
|
|||
uk: "ua",
|
||||
zh: "cn",
|
||||
ko: "kr",
|
||||
es: "es",
|
||||
|
||||
};
|
||||
return "fi-" + (langMap[lang] || lang);
|
||||
|
|
@ -136,20 +150,25 @@ const userDisplayName = computed(() => {
|
|||
<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>
|
||||
</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">
|
||||
<i class="fa-solid fa-circle-half-stroke"></i>
|
||||
<span class="d-lg-none ms-2">Toggle theme</span>
|
||||
</a>
|
||||
<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>
|
||||
<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>
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -182,6 +201,7 @@ const userDisplayName = computed(() => {
|
|||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('uk')"><span class="fi fi-ua"></span> Українська</a>
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('vi')"><span class="fi fi-vi"></span> Tiếng Việt</a>
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('zh')"><span class="fi fi-cn"></span> 中文</a>
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('es')"><span class="fi fi-es"></span> Español</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -219,4 +239,8 @@ const userDisplayName = computed(() => {
|
|||
background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important;
|
||||
color: var(--bs-badge-color)!important;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] .navbar-dark, .navbar {
|
||||
background-color: #000 !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -105,3 +105,7 @@ a.disabled {
|
|||
.vue-tags-input .ti-deletion-mark:after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
|
@ -316,6 +316,16 @@ async function del() {
|
|||
isDeleting.value = true
|
||||
try {
|
||||
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()
|
||||
} catch (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>
|
||||
<input v-model="formData.FirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number">
|
||||
</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>
|
||||
</div>
|
||||
|
|
@ -447,7 +462,7 @@ async function del() {
|
|||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<fieldset v-if="formData.Backend==='local'">
|
||||
<legend class="mt-4">{{ $t('modals.interface-edit.header-hooks') }}</legend>
|
||||
<div class="form-group">
|
||||
<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">
|
||||
<label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label>
|
||||
</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">
|
||||
<label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -358,7 +358,7 @@ async function del() {
|
|||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint.placeholder')"
|
||||
v-model="formData.Endpoint.Value">
|
||||
</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>
|
||||
<vue-tags-input class="form-control" v-model="currentTags.Addresses"
|
||||
:tags="formData.Addresses.map(str => ({ text: str }))"
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ function ConfigQrUrl() {
|
|||
<template>
|
||||
<Modal :title="title" :visible="visible" @close="close">
|
||||
<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>
|
||||
<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">
|
||||
|
|
@ -151,20 +151,28 @@ function ConfigQrUrl() {
|
|||
data-bs-parent="#peerInformation" style="">
|
||||
<div class="accordion-body">
|
||||
<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>
|
||||
<li>{{ $t('modals.peer-view.identifier') }}: {{ selectedPeer.PublicKey }}</li>
|
||||
<li>{{ $t('modals.peer-view.ip') }}: <span v-for="ip in selectedPeer.Addresses" :key="ip"
|
||||
<li v-if="selectedInterface.Mode !== 'client'"><strong>{{ $t('modals.peer-view.identifier') }}</strong>: {{ selectedPeer.PublicKey }}</li>
|
||||
<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>
|
||||
<li>{{ $t('modals.peer-view.user') }}: {{ selectedPeer.UserIdentifier }}</li>
|
||||
<li v-if="selectedPeer.Notes">{{ $t('modals.peer-view.notes') }}: {{ selectedPeer.Notes }}</li>
|
||||
<li v-if="selectedPeer.ExpiresAt">{{ $t('modals.peer-view.expiry-status') }}: {{
|
||||
<li v-if="selectedInterface.Mode === 'server'"><strong>{{ $t('modals.peer-view.extra-allowed-ip') }}</strong>: <span v-for="ip in selectedPeer.ExtraAllowedIPs" :key="ip"
|
||||
class="badge rounded-pill bg-light">{{ ip }}</span></li>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
</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">
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -199,7 +207,7 @@ function ConfigQrUrl() {
|
|||
</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">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#collapseConfig" aria-expanded="false" aria-controls="collapseConfig">
|
||||
|
|
@ -217,9 +225,9 @@ function ConfigQrUrl() {
|
|||
</template>
|
||||
<template #footer>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<button @click.prevent="close" type="button" class="btn btn-secondary">{{ $t('general.close') }}</button>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export function freshPeer() {
|
|||
Identifier: "",
|
||||
DisplayName: "",
|
||||
UserIdentifier: "",
|
||||
UserDisplayName: "",
|
||||
InterfaceIdentifier: "",
|
||||
Disabled: false,
|
||||
ExpiresAt: null,
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ export function ipToBigInt(ip) {
|
|||
// Check if it's an IPv4 address
|
||||
if (ip.includes(".")) {
|
||||
const addr = new Address4(ip)
|
||||
return addr.bigInteger()
|
||||
return addr.bigInt()
|
||||
}
|
||||
|
||||
// Otherwise, assume it's an IPv6 address
|
||||
const addr = new Address6(ip)
|
||||
return addr.bigInteger()
|
||||
return addr.bigInt()
|
||||
}
|
||||
|
||||
export function humanFileSize(size) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import ru from './translations/ru.json';
|
|||
import uk from './translations/uk.json';
|
||||
import vi from './translations/vi.json';
|
||||
import zh from './translations/zh.json';
|
||||
import es from './translations/es.json';
|
||||
|
||||
import {createI18n} from "vue-i18n";
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ const i18n = createI18n({
|
|||
"uk": uk,
|
||||
"vi": vi,
|
||||
"zh": zh,
|
||||
"es": es,
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@
|
|||
"dns": "DNS-Server",
|
||||
"mtu": "MTU",
|
||||
"default-keep-alive": "Standard Keepalive-Intervall",
|
||||
"default-dns": "Standard DNS-Server",
|
||||
"button-show-config": "Konfiguration anzeigen",
|
||||
"button-download-config": "Konfiguration herunterladen",
|
||||
"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-register-title": "Passkey registrieren",
|
||||
"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": {
|
||||
|
|
@ -461,6 +472,8 @@
|
|||
"section-config": "Konfiguration",
|
||||
"identifier": "Kennung",
|
||||
"ip": "IP-Adressen",
|
||||
"allowed-ip": "Erlaubte IP-Adressen",
|
||||
"extra-allowed-ip": "Serverseitig erlaubte IP-Adressen",
|
||||
"user": "Zugeordneter Benutzer",
|
||||
"notes": "Notizen",
|
||||
"expiry-status": "Läuft ab am",
|
||||
|
|
@ -473,6 +486,8 @@
|
|||
"handshake": "Letzter Handshake",
|
||||
"connected-since": "Verbunden seit",
|
||||
"endpoint": "Endpunkt",
|
||||
"endpoint-key": "Öffentlicher Endpunkt-Schlüssel",
|
||||
"keepalive": "Persistentes Keepalive",
|
||||
"button-download": "Konfiguration herunterladen",
|
||||
"button-email": "Konfiguration per E-Mail senden",
|
||||
"style-label": "Konfigurationsformat"
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@
|
|||
"dns": "DNS Servers",
|
||||
"mtu": "MTU",
|
||||
"default-keep-alive": "Default Keepalive Interval",
|
||||
"default-dns": "Default DNS Servers",
|
||||
"button-show-config": "Show configuration",
|
||||
"button-download-config": "Download configuration",
|
||||
"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-register-title": "Register Passkey",
|
||||
"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": {
|
||||
|
|
@ -462,6 +473,8 @@
|
|||
"section-config": "Configuration",
|
||||
"identifier": "Identifier",
|
||||
"ip": "IP Addresses",
|
||||
"allowed-ip": "Allowed IP Addresses",
|
||||
"extra-allowed-ip": "Server Side Allowed IP Addresses",
|
||||
"user": "Associated User",
|
||||
"notes": "Notes",
|
||||
"expiry-status": "Expires At",
|
||||
|
|
@ -474,6 +487,8 @@
|
|||
"handshake": "Last Handshake",
|
||||
"connected-since": "Connected since",
|
||||
"endpoint": "Endpoint",
|
||||
"endpoint-key": "Endpoint Public Key",
|
||||
"keepalive": "Persistent Keepalive",
|
||||
"button-download": "Download configuration",
|
||||
"button-email": "Send configuration via E-Mail",
|
||||
"style-label": "Configuration Style"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,587 @@
|
|||
{
|
||||
"languages": {
|
||||
"es": "Español"
|
||||
},
|
||||
"general": {
|
||||
"pagination": {
|
||||
"size": "Numero de elementos",
|
||||
"all": "Todos (Lento)"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar...",
|
||||
"button": "Buscar"
|
||||
},
|
||||
"select-all": "Buscar todos",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"cancel": "Cancelar",
|
||||
"close": "Cerrar",
|
||||
"save": "Guardar",
|
||||
"delete": "Eliminar"
|
||||
},
|
||||
"login": {
|
||||
"headline": "Por favor inicie sesión",
|
||||
"username": {
|
||||
"label": "Usuario",
|
||||
"placeholder": "Por favor ingrese su usuario"
|
||||
},
|
||||
"password": {
|
||||
"label": "Contraseña",
|
||||
"placeholder": "Por favor ingrese su contraseña"
|
||||
},
|
||||
"button": "Ingresar",
|
||||
"button-webauthn": "Usar clave de acceso"
|
||||
},
|
||||
"menu": {
|
||||
"home": "Inicio",
|
||||
"interfaces": "Interfaces",
|
||||
"users": "Usuarios",
|
||||
"lang": "Cambiar idioma",
|
||||
"profile": "Mi perfil",
|
||||
"settings": "Configuración",
|
||||
"audit": "Registro de auditoría",
|
||||
"login": "Iniciar sesión",
|
||||
"logout": "Cerrar sesión",
|
||||
"keygen": "Generador de claves"
|
||||
},
|
||||
"home": {
|
||||
"headline": "Portal VPN WireGuard®",
|
||||
"info-headline": "Más información",
|
||||
"abstract": "WireGuard® es una VPN extremadamente simple pero rápida y moderna que utiliza criptografía de última generación. Su objetivo es ser más rápida, simple, ligera y útil que IPsec, a la vez que evita los enormes problemas que supone. Su objetivo es ofrecer un rendimiento considerablemente superior al de OpenVPN.",
|
||||
"installation": {
|
||||
"box-header": "Instalación de WireGuard",
|
||||
"headline": "Instalación",
|
||||
"content": "Las instrucciones de instalación del cliente se pueden encontrar en el sitio web oficial de WireGuard.",
|
||||
"button": "Abrir instrucciones"
|
||||
},
|
||||
"about-wg": {
|
||||
"box-header": "Acerca de WireGuard",
|
||||
"headline": "Acerca de",
|
||||
"content": "WireGuard® es una VPN extremadamente simple pero rápida y moderna que utiliza criptografía de última generación.",
|
||||
"button": "Más"
|
||||
},
|
||||
"about-portal": {
|
||||
"box-header": "Acerca del Portal WireGuard",
|
||||
"headline": "Portal WireGuard",
|
||||
"content": "WireGuard Portal es un portal web simple para la configuración de WireGuard.",
|
||||
"button": "Más"
|
||||
},
|
||||
"profiles": {
|
||||
"headline": "Perfiles VPN",
|
||||
"abstract": "Puedes acceder y descargar tus configuraciones personales de VPN desde tu perfil de usuario.",
|
||||
"content": "para ver todos tus perfiles configurados, haz clic en el botón de abajo.",
|
||||
"button": "Abrir mi perfil"
|
||||
},
|
||||
"admin": {
|
||||
"headline": "Área de administración",
|
||||
"abstract": "En el área de administración puedes gestionar los peers de WireGuard, la interfaz del servidor, así como los usuarios que tienen acceso al Portal WireGuard.",
|
||||
"content": "",
|
||||
"button-admin": "Abrir administración del servidor",
|
||||
"button-user": "Abrir administración de usuarios"
|
||||
}
|
||||
},
|
||||
"interfaces": {
|
||||
"headline": "Administración de interfaces",
|
||||
"headline-peers": "Peers VPN actuales",
|
||||
"headline-endpoints": "Extremos actuales",
|
||||
"no-interface": {
|
||||
"default-selection": "No hay interfaces disponibles",
|
||||
"headline": "No se encontraron interfaces...",
|
||||
"abstract": "Haz clic en el botón + para crear una nueva interfaz WireGuard."
|
||||
},
|
||||
"no-peer": {
|
||||
"headline": "No hay peers disponibles",
|
||||
"abstract": "Actualmente no hay peers disponibles para la interfaz WireGuard seleccionada."
|
||||
},
|
||||
"table-heading": {
|
||||
"name": "Nombre",
|
||||
"user": "Usuario",
|
||||
"ip": "IP's",
|
||||
"endpoint": "Endpoint",
|
||||
"status": "Estado"
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Estado de la interfaz para",
|
||||
"backend": "Backend",
|
||||
"unknown-backend": "Desconocido",
|
||||
"wrong-backend": "Backend inválido, usando backend local de WireGuard en su lugar.",
|
||||
"key": "Clave pública",
|
||||
"endpoint": "Endpoint público",
|
||||
"port": "Puerto de escucha",
|
||||
"peers": "Peers habilitados",
|
||||
"total-peers": "Peers totales",
|
||||
"endpoints": "Endpoints habilitados",
|
||||
"total-endpoints": "Endpoints totales",
|
||||
"ip": "Dirección IP",
|
||||
"default-allowed-ip": "IPs permitidas por defecto",
|
||||
"dns": "Servidores DNS",
|
||||
"mtu": "MTU",
|
||||
"default-keep-alive": "Intervalo Keepalive por defecto",
|
||||
"button-show-config": "Mostrar configuración",
|
||||
"button-download-config": "Descargar configuración",
|
||||
"button-store-config": "Guardar configuración para wg-quick",
|
||||
"button-edit": "Editar interfaz"
|
||||
},
|
||||
"button-add-interface": "Agregar interfaz",
|
||||
"button-add-peer": "Agregar peer",
|
||||
"button-add-peers": "Agregar múltiples peers",
|
||||
"button-show-peer": "Mostrar peer",
|
||||
"button-edit-peer": "Editar peer",
|
||||
"peer-disabled": "Peer deshabilitado, motivo:",
|
||||
"peer-expiring": "El peer expira en",
|
||||
"peer-connected": "Conectado",
|
||||
"peer-not-connected": "No conectado",
|
||||
"peer-handshake": "Último handshake:"
|
||||
},
|
||||
"users": {
|
||||
"headline": "Administración de usuarios",
|
||||
"table-heading": {
|
||||
"id": "ID",
|
||||
"email": "Correo electrónico",
|
||||
"firstname": "Nombre",
|
||||
"lastname": "Apellido",
|
||||
"source": "Origen",
|
||||
"peers": "Peers",
|
||||
"admin": "Administrador"
|
||||
},
|
||||
"no-user": {
|
||||
"headline": "No hay usuarios disponibles",
|
||||
"abstract": "Actualmente no hay usuarios registrados en el Portal WireGuard."
|
||||
},
|
||||
"button-add-user": "Agregar usuario",
|
||||
"button-show-user": "Mostrar usuario",
|
||||
"button-edit-user": "Editar usuario",
|
||||
"user-disabled": "Usuario deshabilitado, motivo:",
|
||||
"user-locked": "Cuenta bloqueada, motivo:",
|
||||
"admin": "El usuario tiene privilegios de administrador",
|
||||
"no-admin": "El usuario no tiene privilegios de administrador"
|
||||
},
|
||||
"profile": {
|
||||
"headline": "Mis peers VPN",
|
||||
"table-heading": {
|
||||
"name": "Nombre",
|
||||
"ip": "IP's",
|
||||
"stats": "Estado",
|
||||
"interface": "Interfaz del servidor"
|
||||
},
|
||||
"no-peer": {
|
||||
"headline": "No hay peers disponibles",
|
||||
"abstract": "Actualmente no hay peers asociados a tu perfil de usuario."
|
||||
},
|
||||
"peer-connected": "Conectado",
|
||||
"button-add-peer": "Agregar peer",
|
||||
"button-show-peer": "Mostrar peer",
|
||||
"button-edit-peer": "Editar peer"
|
||||
},
|
||||
"settings": {
|
||||
"headline": "Configuración",
|
||||
"abstract": "Aquí puedes cambiar tu configuración personal.",
|
||||
"api": {
|
||||
"headline": "Configuración de API",
|
||||
"abstract": "Aquí puedes configurar los ajustes de la API RESTful.",
|
||||
"active-description": "La API está actualmente activa para tu cuenta. Todas las solicitudes están autenticadas con Basic Auth. Usa las siguientes credenciales.",
|
||||
"inactive-description": "La API está actualmente inactiva. Presiona el botón de abajo para activarla.",
|
||||
"user-label": "Usuario de la API:",
|
||||
"user-placeholder": "Usuario de la API",
|
||||
"token-label": "Contraseña de la API:",
|
||||
"token-placeholder": "Token de la API",
|
||||
"token-created-label": "Acceso API concedido en: ",
|
||||
"button-disable-title": "Desactivar API, invalidará el token actual.",
|
||||
"button-disable-text": "Desactivar API",
|
||||
"button-enable-title": "Activar API, generará un nuevo token.",
|
||||
"button-enable-text": "Activar API",
|
||||
"api-link": "Documentación de API"
|
||||
},
|
||||
"webauthn": {
|
||||
"headline": "Configuración de llave de acceso",
|
||||
"abstract": "Las llaves de acceso son una forma moderna de autenticar usuarios sin necesidad de contraseñas. Se almacenan de forma segura en tu navegador y pueden usarse para iniciar sesión en el Portal WireGuard.",
|
||||
"active-description": "Al menos una llave de acceso está activa en tu cuenta.",
|
||||
"inactive-description": "Actualmente no hay llaves de acceso registradas. Presiona el botón de abajo para registrar una.",
|
||||
"table": {
|
||||
"name": "Nombre",
|
||||
"created": "Creada",
|
||||
"actions": ""
|
||||
},
|
||||
"credentials-list": "Llaves de acceso registradas actualmente",
|
||||
"modal-delete": {
|
||||
"headline": "Eliminar llaves de acceso",
|
||||
"abstract": "¿Seguro que deseas eliminar esta llave de acceso? Ya no podrás usarla para iniciar sesión.",
|
||||
"created": "Creada:",
|
||||
"button-delete": "Eliminar",
|
||||
"button-cancel": "Cancelar"
|
||||
},
|
||||
"button-rename-title": "Renombrar",
|
||||
"button-rename-text": "Renombrar la llave de acceso.",
|
||||
"button-save-title": "Guardar",
|
||||
"button-save-text": "Guardar el nuevo nombre de la llave de acceso.",
|
||||
"button-cancel-title": "Cancelar",
|
||||
"button-cancel-text": "Cancelar el renombrado de la llave de acceso.",
|
||||
"button-delete-title": "Eliminar",
|
||||
"button-delete-text": "Eliminar la llave de acceso. Ya no podrás iniciar sesión con ella.",
|
||||
"button-register-title": "Registrar llave de acceso",
|
||||
"button-register-text": "Registrar una nueva llave de acceso para proteger tu cuenta."
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"headline": "Registro de Auditoría",
|
||||
"abstract": "Aquí puedes encontrar el registro de auditoría de todas las acciones realizadas en el Portal WireGuard.",
|
||||
"no-entries": {
|
||||
"headline": "No hay entradas en el registro",
|
||||
"abstract": "Actualmente no se han registrado auditorías."
|
||||
},
|
||||
"entries-headline": "Entradas del Registro",
|
||||
"table-heading": {
|
||||
"id": "#",
|
||||
"time": "Hora",
|
||||
"user": "Usuario",
|
||||
"severity": "Severidad",
|
||||
"origin": "Origen",
|
||||
"message": "Mensaje"
|
||||
}
|
||||
},
|
||||
"keygen": {
|
||||
"headline": "Generador de claves WireGuard",
|
||||
"abstract": "Genera nuevas claves de WireGuard. Las claves se generan en tu navegador local y nunca se envían al servidor.",
|
||||
"headline-keypair": "Nuevo par de claves",
|
||||
"headline-preshared-key": "Nueva clave pre-compartida",
|
||||
"button-generate": "Generar",
|
||||
"private-key": {
|
||||
"label": "Clave privada",
|
||||
"placeholder": "La clave privada"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Clave pública",
|
||||
"placeholder": "La clave pública"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "Clave pre-compartida",
|
||||
"placeholder": "La clave pre-compartida"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
"user-view": {
|
||||
"headline": "Cuenta de Usuario:",
|
||||
"tab-user": "Información",
|
||||
"tab-peers": "Peers",
|
||||
"headline-info": "Información del Usuario:",
|
||||
"headline-notes": "Notas:",
|
||||
"email": "Correo Electrónico",
|
||||
"firstname": "Nombre",
|
||||
"lastname": "Apellido",
|
||||
"phone": "Número de Teléfono",
|
||||
"depeertment": "Departamento",
|
||||
"api-enabled": "Acceso API",
|
||||
"disabled": "Cuenta Deshabilitada",
|
||||
"locked": "Cuenta Bloqueada",
|
||||
"no-peers": "El usuario no tiene peers asociados.",
|
||||
"peers": {
|
||||
"name": "Nombre",
|
||||
"interface": "Interfaz",
|
||||
"ip": "IP's"
|
||||
}
|
||||
},
|
||||
"user-edit": {
|
||||
"headline-edit": "Editar usuario:",
|
||||
"headline-new": "Nuevo usuario",
|
||||
"header-general": "General",
|
||||
"header-personal": "Información del Usuario",
|
||||
"header-notes": "Notas",
|
||||
"header-state": "Estado",
|
||||
"identifier": {
|
||||
"label": "Identificador",
|
||||
"placeholder": "El identificador único del usuario"
|
||||
},
|
||||
"source": {
|
||||
"label": "Origen",
|
||||
"placeholder": "El origen del usuario"
|
||||
},
|
||||
"password": {
|
||||
"label": "Contraseña",
|
||||
"placeholder": "Una contraseña súper segura",
|
||||
"description": "Deja este campo en blanco para mantener la contraseña actual.",
|
||||
"too-weak": "La contraseña es demasiado débil. Por favor usa una más fuerte."
|
||||
},
|
||||
"email": {
|
||||
"label": "Correo",
|
||||
"placeholder": "La dirección de correo"
|
||||
},
|
||||
"phone": {
|
||||
"label": "Teléfono",
|
||||
"placeholder": "El número de teléfono"
|
||||
},
|
||||
"depeertment": {
|
||||
"label": "Departamento",
|
||||
"placeholder": "El departamento"
|
||||
},
|
||||
"firstname": {
|
||||
"label": "Nombre",
|
||||
"placeholder": "Nombre"
|
||||
},
|
||||
"lastname": {
|
||||
"label": "Apellido",
|
||||
"placeholder": "Apellido"
|
||||
},
|
||||
"notes": {
|
||||
"label": "Notas",
|
||||
"placeholder": ""
|
||||
},
|
||||
"disabled": {
|
||||
"label": "Deshabilitado (sin conexión WireGuard y sin posibilidad de inicio de sesión)"
|
||||
},
|
||||
"locked": {
|
||||
"label": "Bloqueado (no es posible iniciar sesión, las conexiones WireGuard aún funcionan)"
|
||||
},
|
||||
"admin": {
|
||||
"label": "Es administrador"
|
||||
}
|
||||
},
|
||||
"interface-view": {
|
||||
"headline": "Configuración de la interfaz:"
|
||||
},
|
||||
"interface-edit": {
|
||||
"headline-edit": "Editar interfaz:",
|
||||
"headline-new": "Nueva interfaz",
|
||||
"tab-interface": "Interfaz",
|
||||
"tab-peerdef": "Valores predeterminados del peer",
|
||||
"header-general": "General",
|
||||
"header-network": "Red",
|
||||
"header-crypto": "Criptografía",
|
||||
"header-hooks": "Hooks de interfaz",
|
||||
"header-peer-hooks": "Hooks",
|
||||
"header-state": "Estado",
|
||||
"identifier": {
|
||||
"label": "Identificador",
|
||||
"placeholder": "El identificador único de la interfaz"
|
||||
},
|
||||
"mode": {
|
||||
"label": "Modo de Interfaz",
|
||||
"server": "Modo Servidor",
|
||||
"client": "Modo Cliente",
|
||||
"any": "Modo Desconocido"
|
||||
},
|
||||
"backend": {
|
||||
"label": "Backend de la Interfaz",
|
||||
"invalid-label": "El backend original ya no está disponible, usando el backend local de WireGuard en su lugar.",
|
||||
"local": "Backend local de WireGuard"
|
||||
},
|
||||
"display-name": {
|
||||
"label": "Nombre para Mostrar",
|
||||
"placeholder": "El nombre descriptivo de la interfaz"
|
||||
},
|
||||
"private-key": {
|
||||
"label": "La clave Privada",
|
||||
"placeholder": "La clave privada"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "La clave pública",
|
||||
"placeholder": "La clave pública"
|
||||
},
|
||||
"ip": {
|
||||
"label": "Direcciones IP",
|
||||
"placeholder": "Direcciones IP (formato CIDR)"
|
||||
},
|
||||
"listen-port": {
|
||||
"label": "Puerto de Escucha",
|
||||
"placeholder": "El puerto de escucha"
|
||||
},
|
||||
"dns": {
|
||||
"label": "Servidor DNS",
|
||||
"placeholder": "Los servidores DNS que deben usarse"
|
||||
},
|
||||
"dns-search": {
|
||||
"label": "Dominios de Búsqueda DNS",
|
||||
"placeholder": "Prefijos de búsqueda DNS"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "La MTU de la interfaz (0 = mantener por defecto)"
|
||||
},
|
||||
"firewall-mark": {
|
||||
"label": "Marca de Firewall",
|
||||
"placeholder": "Marca de firewall que se aplica al tráfico saliente. (0 = automático)"
|
||||
},
|
||||
"routing-table": {
|
||||
"label": "Tabla de Enrutamiento",
|
||||
"placeholder": "El ID de la tabla de enrutamiento",
|
||||
"description": "Casos especiales: off = no administrar rutas, 0 = automático"
|
||||
},
|
||||
"pre-up": {
|
||||
"label": "Pre-Up",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"post-up": {
|
||||
"label": "Post-Up",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"pre-down": {
|
||||
"label": "Pre-Down",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"post-down": {
|
||||
"label": "Post-Down",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"disabled": {
|
||||
"label": "Interfaz Deshabilitada"
|
||||
},
|
||||
"save-config": {
|
||||
"label": "Guardar automáticamente la configuración de wg-quick"
|
||||
},
|
||||
"defaults": {
|
||||
"endpoint": {
|
||||
"label": "Dirección del Endpoint",
|
||||
"placeholder": "Dirección del Endpoint",
|
||||
"description": "La dirección del endpoint al que los peers se conectarán. (ej: wg.ejemplo.com o wg.ejemplo.com:51820)"
|
||||
},
|
||||
"networks": {
|
||||
"label": "Redes IP",
|
||||
"placeholder": "Direcciones de Red",
|
||||
"description": "Los peers obtendrán direcciones IP de esas subredes."
|
||||
},
|
||||
"allowed-ip": {
|
||||
"label": "Direcciones IP Permitidas",
|
||||
"placeholder": "Direcciones IP Permitidas por Defecto"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "La MTU del cliente (0 = mantener por defecto)"
|
||||
},
|
||||
"keep-alive": {
|
||||
"label": "Intervalo de Keep Alive",
|
||||
"placeholder": "Keepalive Persistente (0 = por defecto)"
|
||||
}
|
||||
},
|
||||
"button-apply-defaults": "Aplicar Valores Predeterminados de peers"
|
||||
},
|
||||
"peer-view": {
|
||||
"headline-peer": "Peer:",
|
||||
"headline-endpoint": "Endpoint:",
|
||||
"section-info": "Información del peer",
|
||||
"section-status": "Estado Actual",
|
||||
"section-config": "Configuración",
|
||||
"identifier": "Identificador",
|
||||
"ip": "Direcciones IP",
|
||||
"user": "Usuario Asociado",
|
||||
"notes": "Notas",
|
||||
"expiry-status": "Expira en",
|
||||
"disabled-status": "Deshabilitado en",
|
||||
"traffic": "Tráfico",
|
||||
"connection-status": "Estadísticas de Conexión",
|
||||
"upload": "Bytes Subidos (del Servidor al peer)",
|
||||
"download": "Bytes Descargados (del peer al Servidor)",
|
||||
"pingable": "Es Alcanzable (Ping)",
|
||||
"handshake": "Último Handshake",
|
||||
"connected-since": "Conectado desde",
|
||||
"endpoint": "Endpoint",
|
||||
"button-download": "Descargar configuración",
|
||||
"button-email": "Enviar configuración por Correo Electrónico",
|
||||
"style-label": "Estilo de Configuración"
|
||||
},
|
||||
"peer-edit": {
|
||||
"headline-edit-peer": "Editar peer:",
|
||||
"headline-edit-endpoint": "Editar endpoint:",
|
||||
"headline-new-peer": "Crear peer",
|
||||
"headline-new-endpoint": "Crear endpoint",
|
||||
"header-general": "General",
|
||||
"header-network": "Red",
|
||||
"header-crypto": "Criptografía",
|
||||
"header-hooks": "Hooks (Ejecutados en el peer)",
|
||||
"header-state": "Estado",
|
||||
"display-name": {
|
||||
"label": "Nombre para Mostrar",
|
||||
"placeholder": "El nombre descriptivo para el peer"
|
||||
},
|
||||
"linked-user": {
|
||||
"label": "Usuario Vinculado",
|
||||
"placeholder": "La cuenta de usuario que posee este peer"
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Clave Privada",
|
||||
"placeholder": "Clave privada",
|
||||
"help": "La clave privada se almacena de forma segura en el servidor. Si el usuario ya posee una copia, puedes omitir este campo. El servidor sigue funcionando exclusivamente con la clave pública del peer."
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Cave Pública",
|
||||
"placeholder": "La Clave pública"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "Clave pre-compartida",
|
||||
"placeholder": "Clave pre-compartida opcional"
|
||||
},
|
||||
"endpoint": {
|
||||
"label": "Dirección del endpoint",
|
||||
"placeholder": "La dirección del endpoint remoto"
|
||||
},
|
||||
"ip": {
|
||||
"label": "Direcciones IP",
|
||||
"placeholder": "Direcciones IP (formato CIDR)"
|
||||
},
|
||||
"allowed-ip": {
|
||||
"label": "Direcciones IP permitidas",
|
||||
"placeholder": "Direcciones IP permitidas (formato CIDR)"
|
||||
},
|
||||
"extra-allowed-ip": {
|
||||
"label": "Direcciones IP permitidas extra",
|
||||
"placeholder": "IPs extra permitidas (lado del servidor)",
|
||||
"description": "Esas IPs serán agregadas en la interfaz remota de WireGuard como direcciones IP permitidas."
|
||||
},
|
||||
"dns": {
|
||||
"label": "Servidor DNS",
|
||||
"placeholder": "Los servidores DNS que deben usarse"
|
||||
},
|
||||
"dns-search": {
|
||||
"label": "Dominios de búsqueda DNS",
|
||||
"placeholder": "Prefijos de búsqueda DNS"
|
||||
},
|
||||
"keep-alive": {
|
||||
"label": "Intervalo de Keep Alive",
|
||||
"placeholder": "Keepalive Persistente (0 = por defecto)"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "La MTU del cliente (0 = mantener por defecto)"
|
||||
},
|
||||
"pre-up": {
|
||||
"label": "Pre-Up",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"post-up": {
|
||||
"label": "Post-Up",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"pre-down": {
|
||||
"label": "Pre-Down",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"post-down": {
|
||||
"label": "Post-Down",
|
||||
"placeholder": "Uno o varios comandos bash separados por ;"
|
||||
},
|
||||
"disabled": {
|
||||
"label": "Peer Deshabilitado"
|
||||
},
|
||||
"ignore-global": {
|
||||
"label": "Ignorar configuración global"
|
||||
},
|
||||
"expires-at": {
|
||||
"label": "Fecha de expiración"
|
||||
}
|
||||
},
|
||||
"peer-multi-create": {
|
||||
"headline-peer": "Crear múltiples peers",
|
||||
"headline-endpoint": "Crear múltiples endpoints",
|
||||
"identifiers": {
|
||||
"label": "Identificadores de Usuario",
|
||||
"placeholder": "Identificadores de Usuario",
|
||||
"description": "Un identificador de usuario (el nombre de usuario) para el cual debe crearse un peer."
|
||||
},
|
||||
"prefix": {
|
||||
"headline-peer": "peer:",
|
||||
"headline-endpoint": "Endpoint:",
|
||||
"label": "Prefijo del Nombre peera Mostrar",
|
||||
"placeholder": "El prefijo",
|
||||
"description": "Un prefijo que se agregará al nombre mostrado de los peers."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -115,6 +115,7 @@ export const interfaceStore = defineStore('interfaces', {
|
|||
return apiWrapper.post(`${baseUrl}/new`, formData)
|
||||
.then(iface => {
|
||||
this.interfaces.push(iface)
|
||||
this.selected = iface.Identifier
|
||||
this.fetching = false
|
||||
})
|
||||
.catch(error => {
|
||||
|
|
|
|||
|
|
@ -126,9 +126,14 @@ export const peerStore = defineStore('peers', {
|
|||
if (!statsResponse) {
|
||||
this.stats = {}
|
||||
this.statsEnabled = false
|
||||
} else {
|
||||
this.stats = statsResponse.Stats
|
||||
this.statsEnabled = statsResponse.Enabled
|
||||
}
|
||||
this.stats = statsResponse.Stats
|
||||
this.statsEnabled = statsResponse.Enabled
|
||||
},
|
||||
async Reset() {
|
||||
this.setPeers([])
|
||||
this.setStats(undefined)
|
||||
},
|
||||
async PreparePeer(interfaceId) {
|
||||
return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/prepare`)
|
||||
|
|
@ -186,10 +191,10 @@ export const peerStore = defineStore('peers', {
|
|||
async LoadStats(interfaceId) {
|
||||
// if no interfaceId is given, use the currently selected interface
|
||||
if (!interfaceId) {
|
||||
interfaceId = interfaceStore().GetSelected.Identifier
|
||||
if (!interfaceId) {
|
||||
return // no interface, nothing to load
|
||||
if (!interfaceStore().GetSelected || !interfaceStore().GetSelected.Identifier) {
|
||||
return // no interface, nothing to load
|
||||
}
|
||||
interfaceId = interfaceStore().GetSelected.Identifier
|
||||
}
|
||||
this.fetching = true
|
||||
|
||||
|
|
@ -260,10 +265,10 @@ export const peerStore = defineStore('peers', {
|
|||
async LoadPeers(interfaceId) {
|
||||
// if no interfaceId is given, use the currently selected interface
|
||||
if (!interfaceId) {
|
||||
interfaceId = interfaceStore().GetSelected.Identifier
|
||||
if (!interfaceId) {
|
||||
if (!interfaceStore().GetSelected || !interfaceStore().GetSelected.Identifier) {
|
||||
return // no interface, nothing to load
|
||||
}
|
||||
interfaceId = interfaceStore().GetSelected.Identifier
|
||||
}
|
||||
this.fetching = true
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
this.fetching = true
|
||||
let currentUser = authStore().user.Identifier
|
||||
|
|
|
|||
|
|
@ -217,14 +217,14 @@ onMounted(async () => {
|
|||
<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>
|
||||
</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>
|
||||
<td>{{ $t('interfaces.interface.mtu') }}:</td>
|
||||
<td>{{interfaces.GetSelected.Mtu}}</td>
|
||||
</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>
|
||||
<td>{{ $t('interfaces.interface.default-keep-alive') }}:</td>
|
||||
<td>{{interfaces.GetSelected.PeerDefPersistentKeepalive}}</td>
|
||||
|
|
@ -400,7 +400,7 @@ onMounted(async () => {
|
|||
<span v-if="!peer.Disabled && peer.ExpiresAt" class="text-warning" :title="$t('interfaces.peer-expiring') + ' ' + peer.ExpiresAt"><i class="fas fa-hourglass-end expiring-peer"></i></span>
|
||||
</td>
|
||||
<td><span v-if="peer.DisplayName" :title="peer.Identifier">{{peer.DisplayName}}</span><span v-else :title="peer.Identifier">{{ $filters.truncate(peer.Identifier, 10)}}</span></td>
|
||||
<td>{{peer.UserIdentifier}}</td>
|
||||
<td><span :title="peer.UserDisplayName">{{peer.UserIdentifier}}</span></td>
|
||||
<td>
|
||||
<span v-for="ip in peer.Addresses" :key="ip" class="badge bg-light me-1">{{ ip }}</span>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<script setup>
|
||||
import {onMounted, ref} from "vue";
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import { profileStore } from "@/stores/profile";
|
||||
import { settingsStore } from "@/stores/settings";
|
||||
import { authStore } from "../stores/auth";
|
||||
import {notify} from "@kyvg/vue3-notification";
|
||||
|
||||
const profile = profileStore()
|
||||
const settings = settingsStore()
|
||||
|
|
@ -34,6 +35,45 @@ async function saveRename(credential) {
|
|||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -43,52 +83,45 @@ async function saveRename(credential) {
|
|||
|
||||
<p class="lead">{{ $t('settings.abstract') }}</p>
|
||||
|
||||
<div 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 class="card border-secondary p-5 mt-5" v-if="profile.user.Source === 'db'">
|
||||
<h2 class="display-7">{{ $t('settings.password.headline') }}</h2>
|
||||
<p class="lead">{{ $t('settings.password.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4" for="oldpw">{{ $t('settings.password.current-label') }}</label>
|
||||
<input id="oldpw" v-model="pwFormData.OldPassword" class="form-control" :class="{ 'is-invalid': pwFormData.Password && !pwFormData.OldPassword }" type="password">
|
||||
</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 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 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 class="col-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4" for="confirmnewpw">{{ $t('settings.password.new-confirm-label') }}</label>
|
||||
<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 class="invalid-feedback" v-if="pwFormData.PasswordRepeat !== ''&& pwFormData.Password !== pwFormData.PasswordRepeat">{{ $t('settings.password.invalid-confirm-label') }}</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 class="row mt-5">
|
||||
<div class="col-6">
|
||||
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="updatePassword" :disabled="profile.isFetching || !passwordChangeAllowed">
|
||||
<i class="fa-solid fa-floppy-disk"></i> {{ $t('settings.password.change-button-text') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -173,4 +206,53 @@ async function saveRename(credential) {
|
|||
</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>
|
||||
|
|
|
|||
60
go.mod
60
go.mod
|
|
@ -5,11 +5,11 @@ go 1.24.0
|
|||
require (
|
||||
github.com/a8m/envsubst v1.4.3
|
||||
github.com/alexedwards/scs/v2 v2.9.0
|
||||
github.com/coreos/go-oidc/v3 v3.15.0
|
||||
github.com/coreos/go-oidc/v3 v3.16.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-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/google/uuid v1.6.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/yeqown/go-qrcode/v2 v2.2.5
|
||||
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
|
||||
golang.org/x/crypto v0.42.0
|
||||
golang.org/x/oauth2 v0.31.0
|
||||
golang.org/x/sys v0.36.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/oauth2 v0.32.0
|
||||
golang.org/x/sys v0.37.0
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
|
|
@ -44,22 +44,17 @@ require (
|
|||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.1 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.24.1 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.24.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||
github.com/go-openapi/spec v0.22.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
|
|
@ -69,16 +64,14 @@ require (
|
|||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
github.com/google/go-tpm v0.9.6 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||
github.com/mdlayher/netlink v1.8.0 // indirect
|
||||
|
|
@ -96,18 +89,19 @@ require (
|
|||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // 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/net v0.43.0 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
modernc.org/libc v1.66.8 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // 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
|
||||
)
|
||||
|
|
|
|||
137
go.sum
137
go.sum
|
|
@ -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/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY=
|
||||
github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
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/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
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.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
||||
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/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=
|
||||
|
|
@ -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/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
|
||||
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
|
||||
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
|
||||
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
|
||||
github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM=
|
||||
github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU=
|
||||
github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA=
|
||||
github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8=
|
||||
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||
github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8=
|
||||
github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A=
|
||||
github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I=
|
||||
github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8=
|
||||
github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik=
|
||||
github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c=
|
||||
github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak=
|
||||
github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90=
|
||||
github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k=
|
||||
github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q=
|
||||
github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts=
|
||||
github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0=
|
||||
github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc=
|
||||
github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk=
|
||||
github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk=
|
||||
github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc=
|
||||
github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w=
|
||||
github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM=
|
||||
github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM=
|
||||
github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w=
|
||||
github.com/go-openapi/swag/typeutils v0.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw=
|
||||
github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI=
|
||||
github.com/go-openapi/swag/yamlutils v0.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c=
|
||||
github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
|
||||
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
||||
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
|
||||
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
||||
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
||||
github.com/go-pkgz/routegroup v1.5.3 h1:IvH1KLcQkMap9jucQGBlef3IBloxSAe8USUFvxShFqs=
|
||||
github.com/go-pkgz/routegroup v1.5.3/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
|
|
@ -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/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
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/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
|
||||
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/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
|
|
@ -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/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/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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
|
||||
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
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.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
|
||||
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.8.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.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
||||
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
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-20220722155255-886fb9371eb4/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.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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
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/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=
|
||||
|
|
@ -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.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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
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.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
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.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s=
|
||||
modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
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/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/fileutil v1.3.28 h1:Vp156KUA2nPu9F1NEv036x9UGOjg2qsi5QlWTjZmtMk=
|
||||
modernc.org/fileutil v1.3.28/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
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/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.8 h1:/awsvTnyN/sNjvJm6S3lb7KZw5WV4ly/sBEG7ZUzmIE=
|
||||
modernc.org/libc v1.66.8/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
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/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
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/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
||||
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/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -84,8 +85,8 @@ func NewLocalController(cfg *config.Config) (*LocalController, error) {
|
|||
wg: wg,
|
||||
nl: nl,
|
||||
|
||||
shellCmd: "bash", // we only support bash at the moment
|
||||
resolvConfIfacePrefix: "tun.", // WireGuard interfaces have a tun. prefix in resolvconf
|
||||
shellCmd: "bash", // we only support bash at the moment
|
||||
resolvConfIfacePrefix: cfg.Backend.LocalResolvconfPrefix, // WireGuard interfaces have a tun. prefix in resolvconf
|
||||
}
|
||||
|
||||
return repo, nil
|
||||
|
|
@ -546,7 +547,11 @@ func (c LocalController) deletePeer(deviceId domain.InterfaceIdentifier, id doma
|
|||
|
||||
// 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 == "" {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -560,7 +565,7 @@ func (c LocalController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hoo
|
|||
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 == "" {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -589,7 +594,7 @@ func (c LocalController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearch
|
|||
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"
|
||||
|
||||
err := c.exec(dnsCommand, id)
|
||||
|
|
@ -611,7 +616,7 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
|
|||
if len(stdin) > 0 {
|
||||
b := &bytes.Buffer{}
|
||||
for _, ln := range stdin {
|
||||
if _, err := fmt.Fprint(b, ln); err != nil {
|
||||
if _, err := fmt.Fprint(b, ln+"\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -619,6 +624,8 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
|
|||
}
|
||||
out, err := cmd.CombinedOutput() // execute and wait for output
|
||||
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)
|
||||
}
|
||||
slog.Debug("executed shell command",
|
||||
|
|
@ -631,49 +638,116 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
|
|||
|
||||
// region routing-related
|
||||
|
||||
func (c LocalController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error {
|
||||
// update fwmark rules
|
||||
if err := c.setFwMarkRules(rules); err != nil {
|
||||
return err
|
||||
// SetRoutes sets the routes for the given interface. If no routes are provided, the function is a no-op.
|
||||
func (c LocalController) SetRoutes(_ context.Context, info domain.RoutingTableInfo) error {
|
||||
interfaceId := info.Interface.Identifier
|
||||
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
|
||||
if err := c.setMainRule(rules); err != nil {
|
||||
return err
|
||||
cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps)
|
||||
realTable, realFwMark, err := c.getOrCreateRoutingTableAndFwMark(link, info.Table, info.FwMark)
|
||||
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.cleanupMainRule(rules); err != nil {
|
||||
return err
|
||||
if err := c.setRoutesForFamily(interfaceId, link, netlink.FAMILY_V4, realTable, realFwMark, cidrsV4); err != nil {
|
||||
return fmt.Errorf("failed to set v4 routes: %w", 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
|
||||
}
|
||||
|
||||
func (c LocalController) setFwMarkRules(rules []domain.RouteRule) error {
|
||||
for _, rule := range rules {
|
||||
existingRules, err := c.nl.RuleList(int(rule.IpFamily))
|
||||
func (c LocalController) setRoutesForFamily(
|
||||
interfaceId domain.InterfaceIdentifier,
|
||||
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 {
|
||||
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
|
||||
// next remove old 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 ruleExists {
|
||||
continue // rule already exists, no need to recreate it
|
||||
route := domain.CidrFromIpNet(*rawRoute.Dst)
|
||||
if slices.Contains(cidrs, route) {
|
||||
continue
|
||||
}
|
||||
|
||||
// create a missing rule
|
||||
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{
|
||||
Family: int(rule.IpFamily),
|
||||
Table: rule.Table,
|
||||
Mark: rule.FwMark,
|
||||
Family: family,
|
||||
Table: table,
|
||||
Mark: fwMark,
|
||||
Invert: true,
|
||||
SuppressIfgroup: -1,
|
||||
SuppressPrefixlen: -1,
|
||||
|
|
@ -682,15 +756,102 @@ func (c LocalController) setFwMarkRules(rules []domain.RouteRule) error {
|
|||
Goto: -1,
|
||||
Flow: -1,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to setup %s rule for fwmark %d and table %d: %w",
|
||||
rule.IpFamily, rule.FwMark, rule.Table, err)
|
||||
return fmt.Errorf("failed to setup rule for fwmark %d and table %d for family-id %d: %w",
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
prio := 32700 // linux main rule has a priority of 32766
|
||||
prio := 32700 // linux main rule has a prio of 32766
|
||||
for {
|
||||
isFresh := true
|
||||
for _, existingRule := range existingRules {
|
||||
|
|
@ -708,126 +869,145 @@ func (c LocalController) getRulePriority(existingRules []netlink.Rule) int {
|
|||
return prio
|
||||
}
|
||||
|
||||
func (c LocalController) setMainRule(rules []domain.RouteRule) error {
|
||||
var family domain.IpFamily
|
||||
shouldHaveMainRule := false
|
||||
for _, rule := range rules {
|
||||
family = rule.IpFamily
|
||||
if rule.HasDefault == true {
|
||||
shouldHaveMainRule = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !shouldHaveMainRule {
|
||||
return nil
|
||||
}
|
||||
// RemoveRoutes removes the routes for the given interface. If no routes are provided, the function is a no-op.
|
||||
func (c LocalController) RemoveRoutes(_ context.Context, info domain.RoutingTableInfo) error {
|
||||
interfaceId := info.Interface.Identifier
|
||||
slog.Debug("removing linux routes", "interface", interfaceId, "table", info.Table, "fwMark", info.FwMark,
|
||||
"cidrs", info.AllowedIps)
|
||||
|
||||
existingRules, err := c.nl.RuleList(int(family))
|
||||
wgDev, err := c.wg.Device(string(interfaceId))
|
||||
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
|
||||
}
|
||||
|
||||
ruleExists := false
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
|
||||
ruleExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ruleExists {
|
||||
return nil // rule already exists, skip re-creation
|
||||
}
|
||||
|
||||
if err := c.nl.RuleAdd(&netlink.Rule{
|
||||
Family: int(family),
|
||||
Table: unix.RT_TABLE_MAIN,
|
||||
SuppressIfgroup: -1,
|
||||
SuppressPrefixlen: 0,
|
||||
Priority: c.getMainRulePriority(existingRules),
|
||||
Mark: 0,
|
||||
Mask: nil,
|
||||
Goto: -1,
|
||||
Flow: -1,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to setup rule for main table: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) getMainRulePriority(existingRules []netlink.Rule) int {
|
||||
priority := c.cfg.Advanced.RulePrioOffset
|
||||
for {
|
||||
isFresh := true
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Priority == priority {
|
||||
isFresh = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isFresh {
|
||||
break
|
||||
} else {
|
||||
priority++
|
||||
}
|
||||
}
|
||||
return priority
|
||||
}
|
||||
|
||||
func (c LocalController) cleanupMainRule(rules []domain.RouteRule) error {
|
||||
var family domain.IpFamily
|
||||
for _, rule := range rules {
|
||||
family = rule.IpFamily
|
||||
break
|
||||
}
|
||||
|
||||
existingRules, err := c.nl.RuleList(int(family))
|
||||
link, err := c.nl.LinkByName(string(interfaceId))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing rules for family %s: %w", family, err)
|
||||
slog.Debug("physical link already removed, route cleanup might be incomplete", "interface", interfaceId)
|
||||
link = nil
|
||||
}
|
||||
|
||||
shouldHaveMainRule := false
|
||||
for _, rule := range rules {
|
||||
if rule.HasDefault == true {
|
||||
shouldHaveMainRule = true
|
||||
break
|
||||
fwMark := info.FwMark
|
||||
if wgDev != nil && info.FwMark == 0 {
|
||||
fwMark = uint32(wgDev.FirewallMark)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
mainRules := 0
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
|
||||
mainRules++
|
||||
if table > 0 {
|
||||
err = c.removeRouteRulesForTable(netlink.FAMILY_V4, realTable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove v4 route rules for %s: %w", interfaceId, err)
|
||||
}
|
||||
}
|
||||
|
||||
removalCount := 0
|
||||
if mainRules > 1 {
|
||||
removalCount = mainRules - 1 // we only want one single rule
|
||||
}
|
||||
if !shouldHaveMainRule {
|
||||
removalCount = mainRules
|
||||
}
|
||||
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
|
||||
if removalCount > 0 {
|
||||
existingRule.Family = int(family) // set family, somehow the RuleList method does not populate the family field
|
||||
if err := c.nl.RuleDel(&existingRule); err != nil {
|
||||
return fmt.Errorf("failed to delete main rule: %w", err)
|
||||
}
|
||||
removalCount--
|
||||
}
|
||||
err = c.removeRouteRulesForTable(netlink.FAMILY_V6, realTable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove v6 route rules for %s: %w", interfaceId, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
func (c LocalController) removeRoutesForFamily(
|
||||
interfaceId domain.InterfaceIdentifier,
|
||||
link netlink.Link,
|
||||
family int,
|
||||
table int,
|
||||
fwMark uint32,
|
||||
cidrs []domain.Cidr,
|
||||
) error {
|
||||
// first remove all rules
|
||||
existingRules, err := c.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 := c.nl.RuleDel(&existingRule); err != nil {
|
||||
return fmt.Errorf("failed to delete old fwmark rule: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (c LocalController) removeRouteRulesForTable(
|
||||
family int,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import (
|
|||
"github.com/h44z/wg-portal/internal/lowlevel"
|
||||
)
|
||||
|
||||
const MikrotikRouteDistance = 5
|
||||
const MikrotikDefaultRoutingTable = "main"
|
||||
|
||||
type MikrotikController struct {
|
||||
coreCfg *config.Config
|
||||
cfg *config.BackendMikrotik
|
||||
|
|
@ -22,8 +25,9 @@ type MikrotikController struct {
|
|||
client *lowlevel.MikrotikApiClient
|
||||
|
||||
// Add mutexes to prevent race conditions
|
||||
interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*sync.Mutex
|
||||
peerMutexes sync.Map // map[domain.PeerIdentifier]*sync.Mutex
|
||||
interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*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) {
|
||||
|
|
@ -40,6 +44,7 @@ func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik)
|
|||
|
||||
interfaceMutexes: sync.Map{},
|
||||
peerMutexes: sync.Map{},
|
||||
coreMutex: sync.Mutex{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -763,33 +768,404 @@ func (c *MikrotikController) DeletePeer(
|
|||
|
||||
// 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
|
||||
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 {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
func (c *MikrotikController) SetDNS(
|
||||
ctx context.Context,
|
||||
_ 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)
|
||||
}
|
||||
|
||||
var existingServers []string
|
||||
existingServers = append(existingServers, strings.Split(wgReply.Data.GetString("servers"), ",")...)
|
||||
|
||||
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(id domain.InterfaceIdentifier) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
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
|
||||
|
||||
// region routing-related
|
||||
|
||||
func (c *MikrotikController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
// SetRoutes sets the routes for the given interface. If no routes are provided, the function is a no-op.
|
||||
func (c *MikrotikController) SetRoutes(ctx context.Context, info domain.RoutingTableInfo) error {
|
||||
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)
|
||||
}
|
||||
|
||||
cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps)
|
||||
|
||||
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) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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": {
|
||||
"get": {
|
||||
"produces": [
|
||||
|
|
@ -2159,6 +2191,10 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"UserDisplayName": {
|
||||
"description": "the owner display name",
|
||||
"type": "string"
|
||||
},
|
||||
"UserIdentifier": {
|
||||
"description": "the owner",
|
||||
"type": "string"
|
||||
|
|
|
|||
|
|
@ -322,6 +322,9 @@ definitions:
|
|||
allOf:
|
||||
- $ref: '#/definitions/model.ConfigOption-string'
|
||||
description: the routing table
|
||||
UserDisplayName:
|
||||
description: the owner display name
|
||||
type: string
|
||||
UserIdentifier:
|
||||
description: the owner
|
||||
type: string
|
||||
|
|
@ -1442,6 +1445,27 @@ paths:
|
|||
summary: Enable the REST API for the given user.
|
||||
tags:
|
||||
- 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:
|
||||
get:
|
||||
operationId: users_handleInterfacesGet
|
||||
|
|
|
|||
|
|
@ -17,11 +17,6 @@
|
|||
"paths": {
|
||||
"/interface/all": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -52,16 +47,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/interface/by-id/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/interface/by-id/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -110,14 +105,14 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"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, ...).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
|
@ -182,14 +177,14 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -241,16 +236,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/interface/new": {
|
||||
"post": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"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, ...).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
|
@ -308,16 +303,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/interface/prepare": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/interface/prepare": {
|
||||
"get": {
|
||||
"description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
|
@ -352,16 +347,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/metrics/by-interface/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/metrics/by-interface/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -410,16 +405,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/metrics/by-peer/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/metrics/by-peer/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -468,16 +463,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/metrics/by-user/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/metrics/by-user/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -526,16 +521,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/by-id/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/peer/by-id/{id}": {
|
||||
"get": {
|
||||
"description": "Normal users can only access their own records. Admins can access all records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
|
@ -585,14 +580,14 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"description": "Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
|
@ -657,14 +652,14 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -716,16 +711,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/by-interface/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/peer/by-interface/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -765,16 +760,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/by-user/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/peer/by-user/{id}": {
|
||||
"get": {
|
||||
"description": "Normal users can only access their own records. Admins can access all records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
|
@ -815,16 +810,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/new": {
|
||||
"post": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"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).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
|
@ -882,16 +877,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/prepare/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"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.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
|
@ -947,16 +942,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/provisioning/data/peer-config": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/provisioning/data/peer-config": {
|
||||
"get": {
|
||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||
"produces": [
|
||||
"text/plain",
|
||||
|
|
@ -1013,16 +1008,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/provisioning/data/peer-qr": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/provisioning/data/peer-qr": {
|
||||
"get": {
|
||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||
"produces": [
|
||||
"image/png",
|
||||
|
|
@ -1079,16 +1074,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/provisioning/data/user-info": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/provisioning/data/user-info": {
|
||||
"get": {
|
||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
|
@ -1149,16 +1144,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/provisioning/new-peer": {
|
||||
"post": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/provisioning/new-peer": {
|
||||
"post": {
|
||||
"description": "Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
|
@ -1216,16 +1211,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/all": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/user/all": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -1256,16 +1251,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/by-id/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/user/by-id/{id}": {
|
||||
"get": {
|
||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
|
@ -1315,14 +1310,14 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"description": "Only admins can update existing records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
|
@ -1387,14 +1382,14 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -1446,16 +1441,16 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/new": {
|
||||
"post": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/user/new": {
|
||||
"post": {
|
||||
"description": "Only admins can create new records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
|
@ -1513,7 +1508,12 @@
|
|||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package backend
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"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)
|
||||
}
|
||||
|
||||
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) {
|
||||
return u.wg.GetUserPeers(ctx, id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ type UserService interface {
|
|||
ActivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
||||
// DeactivateApi disables the API for the user with the given id.
|
||||
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(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
|
||||
// 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("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}/change-password", e.handleChangePasswordPost())
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ type Peer struct {
|
|||
Identifier string `json:"Identifier" example:"super_nice_peer"` // peer unique identifier
|
||||
DisplayName string `json:"DisplayName"` // a nice display name/ description for the peer
|
||||
UserIdentifier string `json:"UserIdentifier"` // the owner
|
||||
UserDisplayName string `json:"UserDisplayName"` // the owner display name
|
||||
InterfaceIdentifier string `json:"InterfaceIdentifier"` // the interface id
|
||||
Disabled bool `json:"Disabled"` // flag that specifies if the peer is enabled (up) or not (down)
|
||||
DisabledReason string `json:"DisabledReason"` // the reason why the peer has been disabled
|
||||
|
|
@ -80,7 +81,7 @@ type Peer struct {
|
|||
}
|
||||
|
||||
func NewPeer(src *domain.Peer) *Peer {
|
||||
return &Peer{
|
||||
p := &Peer{
|
||||
Identifier: string(src.Identifier),
|
||||
DisplayName: src.DisplayName,
|
||||
UserIdentifier: string(src.UserIdentifier),
|
||||
|
|
@ -111,6 +112,12 @@ func NewPeer(src *domain.Peer) *Peer {
|
|||
PostDown: ConfigOptionFromDomain(src.Interface.PostDown),
|
||||
Filename: src.GetConfigFileName(),
|
||||
}
|
||||
|
||||
if src.User != nil {
|
||||
p.UserDisplayName = src.User.DisplayName()
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func NewPeers(src []domain.Peer) []Peer {
|
||||
|
|
|
|||
|
|
@ -125,11 +125,27 @@ func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserM
|
|||
// It sets up the external authentication providers (OIDC, OAuth, LDAP) and retries in case of errors.
|
||||
func (a *Authenticator) StartBackgroundJobs(ctx context.Context) {
|
||||
go func() {
|
||||
slog.Debug("setting up external auth providers...")
|
||||
|
||||
// Initialize local copies of authentication providers to allow retry in case of errors
|
||||
oidcQueue := a.cfg.OpenIDConnect
|
||||
oauthQueue := a.cfg.OAuth
|
||||
ldapQueue := a.cfg.Ldap
|
||||
|
||||
// Immediate attempt
|
||||
failedOidc, failedOauth, failedLdap := a.setupExternalAuthProviders(oidcQueue, oauthQueue, ldapQueue)
|
||||
if len(failedOidc) == 0 && len(failedOauth) == 0 && len(failedLdap) == 0 {
|
||||
slog.Info("successfully setup all external auth providers")
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare for retries with only the failed ones
|
||||
oidcQueue = failedOidc
|
||||
oauthQueue = failedOauth
|
||||
ldapQueue = failedLdap
|
||||
slog.Warn("failed to setup some external auth providers, retrying in 30 seconds",
|
||||
"failedOidc", len(failedOidc), "failedOauth", len(failedOauth), "failedLdap", len(failedLdap))
|
||||
|
||||
ticker := time.NewTicker(30 * time.Second) // Ticker for delay between retries
|
||||
defer ticker.Stop()
|
||||
|
||||
|
|
@ -358,12 +374,15 @@ func (a *Authenticator) passwordAuthentication(
|
|||
rawUserInfo, err := ldapAuth.GetUserInfo(context.Background(), identifier)
|
||||
if err != nil {
|
||||
if !errors.Is(err, domain.ErrNotFound) {
|
||||
slog.Warn("failed to fetch ldap user info", "identifier", identifier, "error", err)
|
||||
slog.Warn("failed to fetch ldap user info",
|
||||
"source", ldapAuth.GetName(), "identifier", identifier, "error", err)
|
||||
}
|
||||
continue // user not found / other ldap error
|
||||
}
|
||||
ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo)
|
||||
if err != nil {
|
||||
slog.Error("failed to parse ldap user info",
|
||||
"source", ldapAuth.GetName(), "identifier", identifier, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -376,10 +395,14 @@ func (a *Authenticator) passwordAuthentication(
|
|||
}
|
||||
|
||||
if userSource == "" {
|
||||
slog.Warn("no user source found for user",
|
||||
"identifier", identifier, "ldapProviderCount", len(a.ldapAuthenticators), "inDb", userInDatabase)
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
if userSource == domain.UserSourceLdap && ldapProvider == nil {
|
||||
slog.Warn("no ldap provider found for user",
|
||||
"identifier", identifier, "ldapProviderCount", len(a.ldapAuthenticators), "inDb", userInDatabase)
|
||||
return nil, errors.New("ldap provider not found")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -113,10 +113,13 @@ func (l LdapAuthenticator) GetUserInfo(_ context.Context, userId domain.UserIden
|
|||
}
|
||||
|
||||
if len(sr.Entries) == 0 {
|
||||
slog.Debug("LDAP user not found", "source", l.GetName(), "userId", userId, "filter", loginFilter)
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
|
||||
if len(sr.Entries) > 1 {
|
||||
slog.Debug("LDAP user not unique",
|
||||
"source", l.GetName(), "userId", userId, "filter", loginFilter, "entries", len(sr.Entries))
|
||||
return nil, domain.ErrNotUnique
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,15 +19,16 @@ import (
|
|||
// PlainOauthAuthenticator is an authenticator that uses OAuth for authentication.
|
||||
// User information is retrieved from the specified user info endpoint.
|
||||
type PlainOauthAuthenticator struct {
|
||||
name string
|
||||
cfg *oauth2.Config
|
||||
userInfoEndpoint string
|
||||
client *http.Client
|
||||
userInfoMapping config.OauthFields
|
||||
userAdminMapping *config.OauthAdminMapping
|
||||
registrationEnabled bool
|
||||
userInfoLogging bool
|
||||
allowedDomains []string
|
||||
name string
|
||||
cfg *oauth2.Config
|
||||
userInfoEndpoint string
|
||||
client *http.Client
|
||||
userInfoMapping config.OauthFields
|
||||
userAdminMapping *config.OauthAdminMapping
|
||||
registrationEnabled bool
|
||||
userInfoLogging bool
|
||||
sensitiveInfoLogging bool
|
||||
allowedDomains []string
|
||||
}
|
||||
|
||||
func newPlainOauthAuthenticator(
|
||||
|
|
@ -57,6 +58,7 @@ func newPlainOauthAuthenticator(
|
|||
provider.userAdminMapping = &cfg.AdminMapping
|
||||
provider.registrationEnabled = cfg.RegistrationEnabled
|
||||
provider.userInfoLogging = cfg.LogUserInfo
|
||||
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
|
||||
provider.allowedDomains = cfg.AllowedDomains
|
||||
|
||||
return provider, nil
|
||||
|
|
@ -110,6 +112,10 @@ func (p PlainOauthAuthenticator) GetUserInfo(
|
|||
|
||||
response, err := p.client.Do(req)
|
||||
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)
|
||||
}
|
||||
defer internal.LogClose(response.Body)
|
||||
|
|
@ -121,11 +127,15 @@ func (p PlainOauthAuthenticator) GetUserInfo(
|
|||
var userFields map[string]any
|
||||
err = json.Unmarshal(contents, &userFields)
|
||||
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)
|
||||
}
|
||||
|
||||
if p.userInfoLogging {
|
||||
slog.Debug("OAuth user info",
|
||||
slog.Debug("OAuth: user info debug",
|
||||
"source", p.name,
|
||||
"info", string(contents))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,15 +16,16 @@ import (
|
|||
|
||||
// OidcAuthenticator is an authenticator for OpenID Connect providers.
|
||||
type OidcAuthenticator struct {
|
||||
name string
|
||||
provider *oidc.Provider
|
||||
verifier *oidc.IDTokenVerifier
|
||||
cfg *oauth2.Config
|
||||
userInfoMapping config.OauthFields
|
||||
userAdminMapping *config.OauthAdminMapping
|
||||
registrationEnabled bool
|
||||
userInfoLogging bool
|
||||
allowedDomains []string
|
||||
name string
|
||||
provider *oidc.Provider
|
||||
verifier *oidc.IDTokenVerifier
|
||||
cfg *oauth2.Config
|
||||
userInfoMapping config.OauthFields
|
||||
userAdminMapping *config.OauthAdminMapping
|
||||
registrationEnabled bool
|
||||
userInfoLogging bool
|
||||
sensitiveInfoLogging bool
|
||||
allowedDomains []string
|
||||
}
|
||||
|
||||
func newOidcAuthenticator(
|
||||
|
|
@ -58,6 +59,7 @@ func newOidcAuthenticator(
|
|||
provider.userAdminMapping = &cfg.AdminMapping
|
||||
provider.registrationEnabled = cfg.RegistrationEnabled
|
||||
provider.userInfoLogging = cfg.LogUserInfo
|
||||
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
|
||||
provider.allowedDomains = cfg.AllowedDomains
|
||||
|
||||
return provider, nil
|
||||
|
|
@ -102,24 +104,40 @@ func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token,
|
|||
) {
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
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")
|
||||
}
|
||||
idToken, err := o.verifier.Verify(ctx, rawIDToken)
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
var tokenFields map[string]any
|
||||
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)
|
||||
}
|
||||
|
||||
if o.userInfoLogging {
|
||||
contents, _ := json.Marshal(tokenFields)
|
||||
slog.Debug("OIDC user info",
|
||||
slog.Debug("OIDC: user info debug",
|
||||
"source", o.name,
|
||||
"info", string(contents))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/mail"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"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 == "" {
|
||||
slog.Debug("skipping peer email",
|
||||
"peer", peerId,
|
||||
"reason", "no user linked")
|
||||
continue
|
||||
return fmt.Errorf("peer %s has no user linked, no email is sent", peerId)
|
||||
}
|
||||
|
||||
user, err := m.users.GetUser(ctx, peer.UserIdentifier)
|
||||
if err != nil {
|
||||
slog.Debug("skipping peer email",
|
||||
"peer", peerId,
|
||||
"reason", "unable to fetch user",
|
||||
"error", err)
|
||||
continue
|
||||
email, user := m.resolveEmail(ctx, peer)
|
||||
if email == "" {
|
||||
return fmt.Errorf("peer %s has no valid email address, no email is sent", peerId)
|
||||
}
|
||||
|
||||
if user.Email == "" {
|
||||
slog.Debug("skipping peer email",
|
||||
"peer", peerId,
|
||||
"reason", "user has no mail address")
|
||||
continue
|
||||
}
|
||||
|
||||
err = m.sendPeerEmail(ctx, linkOnly, style, user, peer)
|
||||
err = m.sendPeerEmail(ctx, linkOnly, style, &user, peer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send peer email for %s: %w", peerId, err)
|
||||
}
|
||||
|
|
@ -194,3 +181,37 @@ func (m Manager) sendPeerEmail(
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,25 +4,23 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.org/x/sys/unix"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"sync"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/app"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
"github.com/h44z/wg-portal/internal/lowlevel"
|
||||
)
|
||||
|
||||
// region dependencies
|
||||
|
||||
type ControllerManager interface {
|
||||
// GetController returns the controller for the given interface.
|
||||
GetController(iface domain.Interface) domain.InterfaceController
|
||||
}
|
||||
|
||||
type InterfaceAndPeerDatabaseRepo interface {
|
||||
// GetAllInterfaces returns all interfaces
|
||||
GetAllInterfaces(ctx context.Context) ([]domain.Interface, error)
|
||||
// GetInterfacePeers returns all peers for a given interface
|
||||
GetInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.Peer, error)
|
||||
// GetInterface returns the interface with the given identifier.
|
||||
GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error)
|
||||
}
|
||||
|
||||
type EventBus interface {
|
||||
|
|
@ -30,6 +28,13 @@ type EventBus interface {
|
|||
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
|
||||
|
||||
type routeRuleInfo struct {
|
||||
|
|
@ -45,28 +50,27 @@ type routeRuleInfo struct {
|
|||
type Manager struct {
|
||||
cfg *config.Config
|
||||
|
||||
bus EventBus
|
||||
wg lowlevel.WireGuardClient
|
||||
nl lowlevel.NetlinkClient
|
||||
db InterfaceAndPeerDatabaseRepo
|
||||
bus EventBus
|
||||
db InterfaceAndPeerDatabaseRepo
|
||||
wgController ControllerManager
|
||||
|
||||
mux *sync.Mutex
|
||||
}
|
||||
|
||||
// NewRouteManager creates a new route manager instance.
|
||||
func NewRouteManager(cfg *config.Config, bus EventBus, db InterfaceAndPeerDatabaseRepo) (*Manager, error) {
|
||||
wg, err := wgctrl.New()
|
||||
if err != nil {
|
||||
panic("failed to init wgctrl: " + err.Error())
|
||||
}
|
||||
|
||||
nl := &lowlevel.NetlinkManager{}
|
||||
|
||||
func NewRouteManager(
|
||||
cfg *config.Config,
|
||||
bus EventBus,
|
||||
db InterfaceAndPeerDatabaseRepo,
|
||||
wgController ControllerManager,
|
||||
) (*Manager, error) {
|
||||
m := &Manager{
|
||||
cfg: cfg,
|
||||
bus: bus,
|
||||
|
||||
db: db,
|
||||
wg: wg,
|
||||
nl: nl,
|
||||
db: db,
|
||||
wgController: wgController,
|
||||
mux: &sync.Mutex{},
|
||||
}
|
||||
|
||||
m.connectToMessageBus()
|
||||
|
|
@ -85,419 +89,82 @@ func (m Manager) StartBackgroundJobs(_ context.Context) {
|
|||
// this is a no-op for now
|
||||
}
|
||||
|
||||
func (m Manager) handleRouteUpdateEvent(srcDescription string) {
|
||||
slog.Debug("handling route update event", "source", srcDescription)
|
||||
func (m Manager) handleRouteUpdateEvent(info domain.RoutingTableInfo) {
|
||||
m.mux.Lock() // ensure that only one route update is processed at a time
|
||||
defer m.mux.Unlock()
|
||||
|
||||
err := m.syncRoutes(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("failed to synchronize routes",
|
||||
"source", srcDescription,
|
||||
"error", err)
|
||||
slog.Debug("handling route update event", "info", info.String())
|
||||
|
||||
if !info.ManagementEnabled() {
|
||||
return // route management disabled
|
||||
}
|
||||
|
||||
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) {
|
||||
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())
|
||||
|
||||
if !info.ManagementEnabled() {
|
||||
return // route management disabled
|
||||
}
|
||||
|
||||
if err := m.removeFwMarkRules(info.FwMark, info.GetRoutingTable(), netlink.FAMILY_V4); err != nil {
|
||||
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)
|
||||
err := m.removeRoutes(context.Background(), info)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find all interfaces: %w", err)
|
||||
slog.Error("failed to synchronize routes",
|
||||
"info", info.String(), "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
slog.Debug("routes removed", "info", info.String())
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
existingRules, err := m.nl.RuleList(family)
|
||||
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 {
|
||||
return fmt.Errorf("failed to get existing rules for family %d: %w", family, err)
|
||||
return fmt.Errorf("failed to set routes for interface %s: %w", info.Interface.Identifier, 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)
|
||||
func (m Manager) removeRoutes(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 removal",
|
||||
"interface", info.Interface.Identifier)
|
||||
return nil
|
||||
}
|
||||
|
||||
err := rc.RemoveRoutes(ctx, info)
|
||||
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 fmt.Errorf("failed to remove routes for interface %s: %w", info.Interface.Identifier, 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
|
||||
}
|
||||
|
||||
func (m Manager) setFwMark(id domain.InterfaceIdentifier, fwmark int) error {
|
||||
err := m.wg.ConfigureDevice(string(id), wgtypes.Config{
|
||||
FirewallMark: &fwmark,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update fwmark to: %d: %w", fwmark, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) containsDefaultRoute(allowedIPs []domain.Cidr) (ipV4, ipV6 bool) {
|
||||
for _, allowedIP := range allowedIPs {
|
||||
if ipV4 && ipV6 {
|
||||
break // speed up
|
||||
}
|
||||
|
||||
if allowedIP.Prefix().Bits() == 0 {
|
||||
if allowedIP.IsV4() {
|
||||
ipV4 = true
|
||||
} else {
|
||||
ipV6 = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package wireguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
|
|
@ -12,33 +11,9 @@ import (
|
|||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
type InterfaceController interface {
|
||||
GetId() domain.InterfaceBackend
|
||||
GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error)
|
||||
GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error)
|
||||
GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error)
|
||||
SaveInterface(
|
||||
_ context.Context,
|
||||
id domain.InterfaceIdentifier,
|
||||
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
|
||||
) error
|
||||
DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error
|
||||
SavePeer(
|
||||
_ context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
|
||||
) error
|
||||
DeletePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error
|
||||
PingAddresses(
|
||||
ctx context.Context,
|
||||
addr string,
|
||||
) (*domain.PingerResult, error)
|
||||
}
|
||||
|
||||
type backendInstance struct {
|
||||
Config config.BackendBase // Config is the configuration for the backend instance.
|
||||
Implementation InterfaceController
|
||||
Implementation domain.InterfaceController
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@ type InterfaceAndPeerDatabaseRepo interface {
|
|||
}
|
||||
|
||||
type WgQuickController interface {
|
||||
ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error
|
||||
SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error
|
||||
UnsetDNS(id domain.InterfaceIdentifier) error
|
||||
ExecuteInterfaceHook(ctx context.Context, id domain.InterfaceIdentifier, hookCmd string) error
|
||||
SetDNS(ctx context.Context, id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error
|
||||
UnsetDNS(ctx context.Context, id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error
|
||||
}
|
||||
|
||||
type EventBus interface {
|
||||
|
|
@ -53,11 +53,10 @@ type EventBus interface {
|
|||
// endregion dependencies
|
||||
|
||||
type Manager struct {
|
||||
cfg *config.Config
|
||||
bus EventBus
|
||||
db InterfaceAndPeerDatabaseRepo
|
||||
wg *ControllerManager
|
||||
quick WgQuickController
|
||||
cfg *config.Config
|
||||
bus EventBus
|
||||
db InterfaceAndPeerDatabaseRepo
|
||||
wg *ControllerManager
|
||||
|
||||
userLockMap *sync.Map
|
||||
}
|
||||
|
|
@ -66,7 +65,6 @@ func NewWireGuardManager(
|
|||
cfg *config.Config,
|
||||
bus EventBus,
|
||||
wg *ControllerManager,
|
||||
quick WgQuickController,
|
||||
db InterfaceAndPeerDatabaseRepo,
|
||||
) (*Manager, error) {
|
||||
m := &Manager{
|
||||
|
|
@ -74,7 +72,6 @@ func NewWireGuardManager(
|
|||
bus: bus,
|
||||
wg: wg,
|
||||
db: db,
|
||||
quick: quick,
|
||||
userLockMap: &sync.Map{},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -453,7 +453,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
|
|||
return err
|
||||
}
|
||||
|
||||
existingInterface, err := m.db.GetInterface(ctx, id)
|
||||
existingInterface, existingPeers, err := m.db.GetInterfaceAndPeers(ctx, id)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
existingInterface.Disabled = &now // simulate a disabled interface
|
||||
existingInterface.DisabledReason = domain.DisabledReasonDeleted
|
||||
|
||||
physicalInterface, _ := m.wg.GetController(*existingInterface).GetInterface(ctx, id)
|
||||
|
||||
if err := m.handleInterfacePreSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil {
|
||||
if err := m.handleInterfacePreSaveHooks(ctx, existingInterface, !existingInterface.IsDisabled(),
|
||||
false); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -488,16 +496,12 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
|
|||
return fmt.Errorf("deletion failure: %w", err)
|
||||
}
|
||||
|
||||
fwMark := existingInterface.FirewallMark
|
||||
if physicalInterface != nil && fwMark == 0 {
|
||||
fwMark = physicalInterface.FirewallMark
|
||||
}
|
||||
m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{
|
||||
FwMark: fwMark,
|
||||
Table: existingInterface.GetRoutingTable(),
|
||||
})
|
||||
|
||||
if err := m.handleInterfacePostSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil {
|
||||
if err := m.handleInterfacePostSaveHooks(
|
||||
ctx,
|
||||
existingInterface,
|
||||
!existingInterface.IsDisabled(),
|
||||
false,
|
||||
); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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() {
|
||||
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{
|
||||
FwMark: fwMark,
|
||||
Table: iface.GetRoutingTable(),
|
||||
Interface: *iface,
|
||||
AllowedIps: iface.GetAllowedIPs(peers),
|
||||
FwMark: iface.FirewallMark,
|
||||
Table: iface.GetRoutingTable(),
|
||||
TableStr: iface.RoutingTable,
|
||||
})
|
||||
} 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)
|
||||
}
|
||||
|
||||
|
|
@ -618,60 +641,90 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
|
|||
return iface, nil
|
||||
}
|
||||
|
||||
func (m Manager) getInterfaceStateHistory(ctx context.Context, iface *domain.Interface) (oldEnabled, newEnabled bool) {
|
||||
oldInterface, err := m.db.GetInterface(ctx, iface.Identifier)
|
||||
if err != nil {
|
||||
return false, !iface.IsDisabled() // if the interface did not exist, we assume it was not enabled
|
||||
}
|
||||
|
||||
return !oldInterface.IsDisabled(), !iface.IsDisabled()
|
||||
func (m Manager) getInterfaceStateHistory(
|
||||
oldInterface *domain.Interface,
|
||||
iface *domain.Interface,
|
||||
) (oldEnabled, newEnabled, routeTableChanged bool) {
|
||||
return !oldInterface.IsDisabled(), !iface.IsDisabled(), oldInterface.RoutingTable != iface.RoutingTable
|
||||
}
|
||||
|
||||
func (m Manager) handleInterfacePreSaveActions(iface *domain.Interface) error {
|
||||
if !iface.IsDisabled() {
|
||||
if err := m.quick.SetDNS(iface.Identifier, iface.DnsStr, iface.DnsSearchStr); err != nil {
|
||||
return fmt.Errorf("failed to update dns settings: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := m.quick.UnsetDNS(iface.Identifier); err != nil {
|
||||
return fmt.Errorf("failed to clear dns settings: %w", err)
|
||||
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
|
||||
}
|
||||
|
||||
// update DNS settings only for client interfaces
|
||||
if iface.Type == domain.InterfaceTypeClient || iface.Type == domain.InterfaceTypeAny {
|
||||
if !iface.IsDisabled() {
|
||||
if err := wgQuickController.SetDNS(ctx, iface.Identifier, iface.DnsStr, iface.DnsSearchStr); err != nil {
|
||||
return fmt.Errorf("failed to update dns settings: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := wgQuickController.UnsetDNS(ctx, iface.Identifier, iface.DnsStr, iface.DnsSearchStr); err != nil {
|
||||
return fmt.Errorf("failed to clear dns settings: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return nil // do nothing if state did not change
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
} 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 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 {
|
||||
return nil // do nothing if state did not change
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -799,7 +852,7 @@ func (m Manager) getFreshListenPort(ctx context.Context) (port int, err error) {
|
|||
|
||||
func (m Manager) importInterface(
|
||||
ctx context.Context,
|
||||
backend InterfaceController,
|
||||
backend domain.InterfaceController,
|
||||
in *domain.PhysicalInterface,
|
||||
peers []domain.PhysicalPeer,
|
||||
) error {
|
||||
|
|
@ -901,13 +954,9 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) deleteInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) error {
|
||||
iface, allPeers, err := m.db.GetInterfaceAndPeers(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
func (m Manager) deleteInterfacePeers(ctx context.Context, iface *domain.Interface, allPeers []domain.Peer) error {
|
||||
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) {
|
||||
return fmt.Errorf("wireguard peer deletion failure for %s: %w", peer.Identifier, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
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)
|
||||
// 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
|
||||
m.bus.Publish(app.TopicPeerInterfaceUpdated, peer.InterfaceIdentifier)
|
||||
|
||||
|
|
@ -438,20 +449,26 @@ func (m Manager) GetUserPeerStats(ctx context.Context, id domain.UserIdentifier)
|
|||
// region helper-functions
|
||||
|
||||
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 {
|
||||
iface, err := m.db.GetInterface(ctx, peer.InterfaceIdentifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to find interface %s: %w", peer.InterfaceIdentifier, err)
|
||||
// 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)
|
||||
if err != nil {
|
||||
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
|
||||
// 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)
|
||||
|
||||
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) {
|
||||
domain.MergeToPhysicalPeer(pp, peer)
|
||||
return pp, nil
|
||||
|
|
@ -475,13 +492,22 @@ func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error {
|
|||
Peer: *peer,
|
||||
},
|
||||
})
|
||||
|
||||
interfaces[peer.InterfaceIdentifier] = struct{}{}
|
||||
}
|
||||
|
||||
// Update routes after peers have changed
|
||||
if len(interfaces) != 0 {
|
||||
m.bus.Publish(app.TopicRouteUpdate, "peers updated")
|
||||
for id, iface := range interfaces {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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.
|
||||
|
|
@ -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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ type Backend struct {
|
|||
// 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")
|
||||
LocalResolvconfPrefix string `yaml:"local_resolvconf_prefix"` // The prefix to use for interface names when passing them to resolvconf.
|
||||
|
||||
// External Backend-specific configuration
|
||||
|
||||
|
|
|
|||
|
|
@ -134,6 +134,9 @@ func defaultConfig() *Config {
|
|||
|
||||
cfg.Backend = Backend{
|
||||
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{
|
||||
|
|
|
|||
|
|
@ -41,4 +41,6 @@ type MailConfig struct {
|
|||
From string `yaml:"from"`
|
||||
// LinkOnly specifies whether emails should only contain a link to WireGuard Portal or attach the full configuration
|
||||
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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -132,17 +133,30 @@ func (i *Interface) GetConfigFileName() string {
|
|||
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 {
|
||||
var allowedCidrs []Cidr
|
||||
|
||||
for _, peer := range peers {
|
||||
for _, ip := range peer.Interface.Addresses {
|
||||
allowedCidrs = append(allowedCidrs, ip.HostAddr())
|
||||
switch i.Type {
|
||||
case InterfaceTypeServer, InterfaceTypeAny:
|
||||
for _, peer := range peers {
|
||||
for _, ip := range peer.Interface.Addresses {
|
||||
allowedCidrs = append(allowedCidrs, ip.HostAddr())
|
||||
}
|
||||
if peer.ExtraAllowedIPsStr != "" {
|
||||
extraIPs, err := CidrsFromString(peer.ExtraAllowedIPsStr)
|
||||
if err == nil {
|
||||
allowedCidrs = append(allowedCidrs, extraIPs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
if peer.ExtraAllowedIPsStr != "" {
|
||||
extraIPs, err := CidrsFromString(peer.ExtraAllowedIPsStr)
|
||||
case InterfaceTypeClient:
|
||||
for _, peer := range peers {
|
||||
allowedIPs, err := CidrsFromString(peer.AllowedIPsStr.GetValue())
|
||||
if err == nil {
|
||||
allowedCidrs = append(allowedCidrs, extraIPs...)
|
||||
allowedCidrs = append(allowedCidrs, allowedIPs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -159,6 +173,7 @@ func (i *Interface) ManageRoutingTable() bool {
|
|||
//
|
||||
// -1 if RoutingTable was set to "off" or an error occurred
|
||||
func (i *Interface) GetRoutingTable() int {
|
||||
|
||||
routingTableStr := strings.ToLower(i.RoutingTable)
|
||||
switch {
|
||||
case routingTableStr == "":
|
||||
|
|
@ -166,6 +181,9 @@ func (i *Interface) GetRoutingTable() int {
|
|||
case routingTableStr == "off":
|
||||
return -1
|
||||
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", "")
|
||||
routingTable, err := strconv.ParseUint(numberStr, 16, 64)
|
||||
if err != nil {
|
||||
|
|
@ -178,6 +196,9 @@ func (i *Interface) GetRoutingTable() int {
|
|||
}
|
||||
return int(routingTable)
|
||||
default:
|
||||
if i.Backend != config.LocalBackendName {
|
||||
return 0 // ignore numeric routing table numbers for non-local controllers
|
||||
}
|
||||
routingTable, err := strconv.Atoi(routingTableStr)
|
||||
if err != nil {
|
||||
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 {
|
||||
FwMark uint32
|
||||
Table int
|
||||
Interface Interface
|
||||
AllowedIps []Cidr
|
||||
FwMark uint32
|
||||
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 {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
)
|
||||
|
||||
func TestInterface_IsDisabledReturnsTrueWhenDisabled(t *testing.T) {
|
||||
|
|
@ -37,8 +39,9 @@ func TestInterface_GetConfigFileNameReturnsCorrectFileName(t *testing.T) {
|
|||
assert.Equal(t, expected, iface.GetConfigFileName())
|
||||
}
|
||||
|
||||
func TestInterface_GetAllowedIPsReturnsCorrectCidrs(t *testing.T) {
|
||||
func TestInterface_GetAllowedIPsReturnsCorrectCidrsServerMode(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},
|
||||
|
|
@ -46,16 +49,45 @@ func TestInterface_GetAllowedIPsReturnsCorrectCidrs(t *testing.T) {
|
|||
},
|
||||
}
|
||||
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{}
|
||||
iface := &Interface{Type: InterfaceTypeServer}
|
||||
expected := []Cidr{
|
||||
{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.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}))
|
||||
}
|
||||
|
|
@ -66,10 +98,22 @@ func TestInterface_ManageRoutingTableReturnsCorrectValue(t *testing.T) {
|
|||
|
||||
iface.RoutingTable = "100"
|
||||
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) {
|
||||
iface := &Interface{RoutingTable: ""}
|
||||
iface := &Interface{RoutingTable: "", Backend: config.LocalBackendName}
|
||||
assert.Equal(t, 0, iface.GetRoutingTable())
|
||||
|
||||
iface.RoutingTable = "off"
|
||||
|
|
@ -81,3 +125,17 @@ func TestInterface_GetRoutingTableReturnsCorrectValue(t *testing.T) {
|
|||
iface.RoutingTable = "200"
|
||||
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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ func (c Cidr) IsValid() bool {
|
|||
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) {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(str))
|
||||
if err != nil {
|
||||
|
|
@ -199,3 +203,26 @@ func (c Cidr) Contains(other Cidr) bool {
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
|
|
@ -44,6 +46,7 @@ type Peer struct {
|
|||
DisplayName string // a nice display name/ description for the peer
|
||||
Identifier PeerIdentifier `gorm:"primaryKey;column:identifier"` // peer unique identifier
|
||||
UserIdentifier UserIdentifier `gorm:"index;column:user_identifier"` // the owner
|
||||
User *User `gorm:"-"` // the owner user object; loaded automatically after fetch
|
||||
InterfaceIdentifier InterfaceIdentifier `gorm:"index;column:interface_identifier"` // the interface id
|
||||
Disabled *time.Time `gorm:"column:disabled"` // if this field is set, the peer is disabled
|
||||
DisabledReason string // the reason why the peer has been disabled
|
||||
|
|
@ -305,22 +308,33 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
|
|||
|
||||
func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
|
||||
pp.Identifier = p.Identifier
|
||||
pp.Endpoint = p.Endpoint.GetValue()
|
||||
if p.Interface.Type == InterfaceTypeServer {
|
||||
allowedIPs, _ := CidrsFromString(p.AllowedIPsStr.GetValue())
|
||||
extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr)
|
||||
pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...)
|
||||
} else {
|
||||
pp.PresharedKey = p.PresharedKey
|
||||
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()
|
||||
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())
|
||||
extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr)
|
||||
pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...)
|
||||
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))
|
||||
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...)
|
||||
pp.Endpoint = p.Endpoint.GetValue()
|
||||
pp.PersistentKeepalive = p.PersistentKeepalive.GetValue()
|
||||
}
|
||||
pp.PresharedKey = p.PresharedKey
|
||||
pp.PublicKey = p.Interface.PublicKey
|
||||
pp.PersistentKeepalive = p.PersistentKeepalive.GetValue()
|
||||
|
||||
switch pp.ImportSource {
|
||||
case ControllerTypeMikrotik:
|
||||
|
|
@ -348,3 +362,26 @@ type PeerCreationRequest struct {
|
|||
UserIdentifiers []string
|
||||
Prefix string
|
||||
}
|
||||
|
||||
// AfterFind is a GORM hook that automatically loads the associated User object
|
||||
// based on the UserIdentifier field. If the identifier is empty or no user is
|
||||
// found, the User field is set to nil.
|
||||
func (p *Peer) AfterFind(tx *gorm.DB) error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if p.UserIdentifier == "" {
|
||||
p.User = nil
|
||||
return nil
|
||||
}
|
||||
var u User
|
||||
if err := tx.Where("identifier = ?", p.UserIdentifier).First(&u).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
p.User = nil
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
p.User = &u
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,6 +185,27 @@ func (u *User) CopyCalculatedAttributes(src *User) {
|
|||
u.LinkedPeerCount = src.LinkedPeerCount
|
||||
}
|
||||
|
||||
// DisplayName returns the display name of the user.
|
||||
// The display name is the first and last name, or the email address of the user.
|
||||
// If none of these fields are set, the user identifier is returned.
|
||||
func (u *User) DisplayName() string {
|
||||
var displayName string
|
||||
switch {
|
||||
case u.Firstname != "" && u.Lastname != "":
|
||||
displayName = fmt.Sprintf("%s %s", u.Firstname, u.Lastname)
|
||||
case u.Firstname != "":
|
||||
displayName = u.Firstname
|
||||
case u.Lastname != "":
|
||||
displayName = u.Lastname
|
||||
case u.Email != "":
|
||||
displayName = u.Email
|
||||
default:
|
||||
displayName = string(u.Identifier)
|
||||
}
|
||||
|
||||
return displayName
|
||||
}
|
||||
|
||||
// region webauthn
|
||||
|
||||
func (u *User) WebAuthnID() []byte {
|
||||
|
|
@ -209,19 +230,7 @@ func (u *User) WebAuthnName() string {
|
|||
}
|
||||
|
||||
func (u *User) WebAuthnDisplayName() string {
|
||||
var userName string
|
||||
switch {
|
||||
case u.Firstname != "" && u.Lastname != "":
|
||||
userName = fmt.Sprintf("%s %s", u.Firstname, u.Lastname)
|
||||
case u.Firstname != "":
|
||||
userName = u.Firstname
|
||||
case u.Lastname != "":
|
||||
userName = u.Lastname
|
||||
default:
|
||||
userName = string(u.Identifier)
|
||||
}
|
||||
|
||||
return userName
|
||||
return u.DisplayName()
|
||||
}
|
||||
|
||||
func (u *User) WebAuthnCredentials() []webauthn.Credential {
|
||||
|
|
|
|||
|
|
@ -267,6 +267,7 @@ func parseHttpResponse[T any](resp *http.Response, err error) MikrotikApiRespons
|
|||
}
|
||||
|
||||
defer func(Body io.ReadCloser) {
|
||||
_, _ = io.Copy(io.Discard, Body) // ensure to empty the body
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
slog.Error("failed to close response body", "error", err)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,12 @@ repo_name: h44z/wg-portal
|
|||
repo_url: https://github.com/h44z/wg-portal
|
||||
copyright: Copyright © 2023-2025 WireGuard Portal Project
|
||||
|
||||
extra_javascript:
|
||||
- javascript/img-comparison-slider.js
|
||||
|
||||
extra_css:
|
||||
- stylesheets/extra.css
|
||||
- stylesheets/img-comparison-slider.css
|
||||
|
||||
theme:
|
||||
name: material
|
||||
|
|
|
|||
Loading…
Reference in New Issue