mirror of https://github.com/h44z/wg-portal.git
				
				
				
			Compare commits
	
		
			No commits in common. "master" and "v2.1.0-rc.2" have entirely different histories.
		
	
	
		
			master
			...
			v2.1.0-rc.
		
	
		| 
						 | 
				
			
			@ -1,3 +0,0 @@
 | 
			
		|||
# These are supported funding model platforms
 | 
			
		||||
 | 
			
		||||
github: h44z # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
Copyright (c) 2020-2025 Christoph Haas
 | 
			
		||||
Copyright (c) 2020-2023 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 with dark-mode written in Vue.js
 | 
			
		||||
* Responsive multi-language web UI 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)
 | 
			
		||||
* Supports multiple WireGuard backends (wgctrl or MikroTik [BETA])
 | 
			
		||||
* Peer Expiry Feature
 | 
			
		||||
* Handles route and DNS settings like wg-quick does
 | 
			
		||||
* Exposes Prometheus metrics for monitoring and alerting
 | 
			
		||||
| 
						 | 
				
			
			@ -62,17 +62,6 @@ 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    | :x:                |
 | 
			
		||||
| v1.x    | :white_check_mark: |
 | 
			
		||||
 | 
			
		||||
## Reporting a Vulnerability
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -53,6 +53,8 @@ func main() {
 | 
			
		|||
	wireGuard, err := wireguard.NewControllerManager(cfg)
 | 
			
		||||
	internal.AssertNoError(err)
 | 
			
		||||
 | 
			
		||||
	wgQuick := adapters.NewWgQuickRepo()
 | 
			
		||||
 | 
			
		||||
	mailer := adapters.NewSmtpMailRepo(cfg.Mail)
 | 
			
		||||
 | 
			
		||||
	metricsServer := adapters.NewMetricsServer(cfg)
 | 
			
		||||
| 
						 | 
				
			
			@ -91,7 +93,7 @@ func main() {
 | 
			
		|||
	webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager)
 | 
			
		||||
	internal.AssertNoError(err)
 | 
			
		||||
 | 
			
		||||
	wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, database)
 | 
			
		||||
	wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database)
 | 
			
		||||
	internal.AssertNoError(err)
 | 
			
		||||
	wireGuardManager.StartBackgroundJobs(ctx)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -105,7 +107,7 @@ func main() {
 | 
			
		|||
	mailManager, err := mail.NewMailManager(cfg, mailer, cfgFileManager, database, database)
 | 
			
		||||
	internal.AssertNoError(err)
 | 
			
		||||
 | 
			
		||||
	routeManager, err := route.NewRouteManager(cfg, eventBus, database, wireGuard)
 | 
			
		||||
	routeManager, err := route.NewRouteManager(cfg, eventBus, database)
 | 
			
		||||
	internal.AssertNoError(err)
 | 
			
		||||
	routeManager.StartBackgroundJobs(ctx)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 131 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 131 KiB  | 
| 
						 | 
				
			
			@ -15,9 +15,6 @@ 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,7 +28,6 @@ core:
 | 
			
		|||
  
 | 
			
		||||
backend:
 | 
			
		||||
  default: local
 | 
			
		||||
  local_resolvconf_prefix: tun.
 | 
			
		||||
 | 
			
		||||
advanced:
 | 
			
		||||
  log_level: info
 | 
			
		||||
| 
						 | 
				
			
			@ -73,7 +72,6 @@ mail:
 | 
			
		|||
  auth_type: plain
 | 
			
		||||
  from: Wireguard Portal <noreply@wireguard.local>
 | 
			
		||||
  link_only: false
 | 
			
		||||
  allow_peer_email: false
 | 
			
		||||
 | 
			
		||||
auth:
 | 
			
		||||
  oidc: []
 | 
			
		||||
| 
						 | 
				
			
			@ -186,11 +184,6 @@ 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.
 | 
			
		||||
| 
						 | 
				
			
			@ -388,8 +381,6 @@ 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`
 | 
			
		||||
| 
						 | 
				
			
			@ -427,12 +418,6 @@ To send emails to all peers that have a valid email-address as user-identifier,
 | 
			
		|||
- **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
 | 
			
		||||
| 
						 | 
				
			
			@ -512,18 +497,13 @@ 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:** `false`
 | 
			
		||||
- **Default:** *(empty)*
 | 
			
		||||
- **Description:** If `true`, a new user will be created in WireGuard Portal if not already present.
 | 
			
		||||
 | 
			
		||||
#### `log_user_info`
 | 
			
		||||
- **Default:** `false`
 | 
			
		||||
- **Default:** *(empty)*
 | 
			
		||||
- **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
 | 
			
		||||
| 
						 | 
				
			
			@ -590,18 +570,13 @@ 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:** `false`
 | 
			
		||||
- **Default:** *(empty)*
 | 
			
		||||
- **Description:** If `true`, new users are created automatically on successful login.
 | 
			
		||||
 | 
			
		||||
#### `log_user_info`
 | 
			
		||||
- **Default:** `false`
 | 
			
		||||
- **Default:** *(empty)*
 | 
			
		||||
- **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
 | 
			
		||||
| 
						 | 
				
			
			@ -618,11 +593,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:** `false`
 | 
			
		||||
- **Default:** *(empty)*
 | 
			
		||||
- **Description:** If `true`, use STARTTLS to secure the LDAP connection.
 | 
			
		||||
 | 
			
		||||
#### `cert_validation`
 | 
			
		||||
- **Default:** `false`
 | 
			
		||||
- **Default:** *(empty)*
 | 
			
		||||
- **Description:** If `true`, validate the LDAP server’s TLS certificate.
 | 
			
		||||
 | 
			
		||||
#### `tls_certificate_path`
 | 
			
		||||
| 
						 | 
				
			
			@ -692,19 +667,19 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
 | 
			
		|||
  ```
 | 
			
		||||
 | 
			
		||||
#### `disable_missing`
 | 
			
		||||
- **Default:** `false`
 | 
			
		||||
- **Default:** *(empty)*
 | 
			
		||||
- **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal.
 | 
			
		||||
 | 
			
		||||
#### `auto_re_enable`
 | 
			
		||||
- **Default:** `false`
 | 
			
		||||
- **Default:** *(empty)*
 | 
			
		||||
- **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:** `false`
 | 
			
		||||
- **Default:** *(empty)*
 | 
			
		||||
- **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login.
 | 
			
		||||
 | 
			
		||||
#### `log_user_info`
 | 
			
		||||
- **Default:** `false`
 | 
			
		||||
- **Default:** *(empty)*
 | 
			
		||||
- **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
											
										
									
								
							| 
						 | 
				
			
			@ -1,15 +0,0 @@
 | 
			
		|||
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: 0;
 | 
			
		||||
  min-width: 600px;
 | 
			
		||||
  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;
 | 
			
		||||
  background-color:  #fff none repeat scroll 0% 0%;
 | 
			
		||||
  padding: 1.5rem;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
| 
						 | 
				
			
			@ -300,59 +300,6 @@
 | 
			
		|||
  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 -->
 | 
			
		||||
| 
						 | 
				
			
			@ -363,6 +310,7 @@
 | 
			
		|||
          <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"
 | 
			
		||||
| 
						 | 
				
			
			@ -378,34 +326,11 @@
 | 
			
		|||
 | 
			
		||||
  <div class="md-container">
 | 
			
		||||
    <div class="tx-hero__image">
 | 
			
		||||
      <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>
 | 
			
		||||
      <img
 | 
			
		||||
        src="{{config.site_url}}/assets/images/screenshot.png"
 | 
			
		||||
        alt=""
 | 
			
		||||
        draggable="false"
 | 
			
		||||
      >
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| 
						 | 
				
			
			@ -16,7 +16,6 @@
 | 
			
		|||
    "@vojtechlanka/vue-tags-input": "^3.1.1",
 | 
			
		||||
    "bootstrap": "^5.3.7",
 | 
			
		||||
    "bootswatch": "^5.3.7",
 | 
			
		||||
    "cidr-tools": "^11.0.3",
 | 
			
		||||
    "flag-icons": "^7.3.2",
 | 
			
		||||
    "ip-address": "^10.0.1",
 | 
			
		||||
    "is-cidr": "^5.1.1",
 | 
			
		||||
| 
						 | 
				
			
			@ -31,6 +30,6 @@
 | 
			
		|||
  "devDependencies": {
 | 
			
		||||
    "@vitejs/plugin-vue": "^5.2.3",
 | 
			
		||||
    "sass-embedded": "^1.86.3",
 | 
			
		||||
    "vite": "^6.3.6"
 | 
			
		||||
    "vite": "6.3.4"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
<script setup>
 | 
			
		||||
import { RouterLink, RouterView } from 'vue-router';
 | 
			
		||||
import {computed, getCurrentInstance, nextTick, onMounted, ref} from "vue";
 | 
			
		||||
import { computed, getCurrentInstance, onMounted, ref } from "vue";
 | 
			
		||||
import { authStore } from "./stores/auth";
 | 
			
		||||
import { securityStore } from "./stores/security";
 | 
			
		||||
import { settingsStore } from "@/stores/settings";
 | 
			
		||||
| 
						 | 
				
			
			@ -11,13 +11,12 @@ 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
 | 
			
		||||
  switchTheme(getTheme());
 | 
			
		||||
  const theme = localStorage.getItem('wgTheme') || 'light';
 | 
			
		||||
  document.documentElement.setAttribute('data-bs-theme', theme);
 | 
			
		||||
 | 
			
		||||
  await sec.LoadSecurityProperties();
 | 
			
		||||
  await auth.LoadProviders();
 | 
			
		||||
| 
						 | 
				
			
			@ -45,22 +44,10 @@ const switchLanguage = function (lang) {
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getTheme = function () {
 | 
			
		||||
  return localStorage.getItem('wgTheme') || 'auto';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const switchTheme = function (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 + ")");
 | 
			
		||||
  if (document.documentElement.getAttribute('data-bs-theme') !== theme) {
 | 
			
		||||
    localStorage.setItem('wgTheme', theme);
 | 
			
		||||
    document.documentElement.setAttribute('data-bs-theme', bsTheme);
 | 
			
		||||
    document.documentElement.setAttribute('data-bs-theme', theme);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -133,9 +120,6 @@ const userDisplayName = computed(() => {
 | 
			
		|||
          <li class="nav-item">
 | 
			
		||||
            <RouterLink :to="{ name: 'key-generator' }" class="nav-link">{{ $t('menu.keygen') }}</RouterLink>
 | 
			
		||||
          </li>
 | 
			
		||||
          <li class="nav-item">
 | 
			
		||||
            <RouterLink :to="{ name: 'ip-calculator' }" class="nav-link">{{ $t('menu.calculator') }}</RouterLink>
 | 
			
		||||
          </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
 | 
			
		||||
        <div class="navbar-nav d-flex justify-content-end">
 | 
			
		||||
| 
						 | 
				
			
			@ -153,25 +137,20 @@ 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" :key="currentTheme">
 | 
			
		||||
          <div class="nav-item dropdown" data-bs-theme="light">
 | 
			
		||||
            <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-check ms-5" :class="{invisible:currentTheme!=='light'}"></i>
 | 
			
		||||
                  <i class="fa-solid fa-sun"></i><span class="ms-2">Light</span>
 | 
			
		||||
                </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-check ms-5" :class="{invisible:currentTheme!=='dark'}"></i>
 | 
			
		||||
                  <i class="fa-solid fa-moon"></i><span class="ms-2">Dark</span>
 | 
			
		||||
                </button>
 | 
			
		||||
              </li>
 | 
			
		||||
            </ul>
 | 
			
		||||
| 
						 | 
				
			
			@ -242,8 +221,4 @@ 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,7 +105,3 @@ a.disabled {
 | 
			
		|||
.vue-tags-input .ti-deletion-mark:after {
 | 
			
		||||
  transform: scaleX(1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-dialog {
 | 
			
		||||
    box-shadow: none;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -316,16 +316,6 @@ 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)
 | 
			
		||||
| 
						 | 
				
			
			@ -444,11 +434,6 @@ 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>
 | 
			
		||||
| 
						 | 
				
			
			@ -462,7 +447,7 @@ async function del() {
 | 
			
		|||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </fieldset>
 | 
			
		||||
          <fieldset v-if="formData.Backend==='local'">
 | 
			
		||||
          <fieldset>
 | 
			
		||||
            <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>
 | 
			
		||||
| 
						 | 
				
			
			@ -487,7 +472,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" v-if="formData.Backend==='local'">
 | 
			
		||||
            <div class="form-check form-switch">
 | 
			
		||||
              <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" v-if="selectedInterface.Mode !== 'client'">
 | 
			
		||||
        <div class="form-group">
 | 
			
		||||
          <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" v-if="selectedInterface.Mode !== 'client'">
 | 
			
		||||
      <div class="d-flex justify-content-end align-items-center mb-1">
 | 
			
		||||
        <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,28 +151,20 @@ function ConfigQrUrl() {
 | 
			
		|||
            data-bs-parent="#peerInformation" style="">
 | 
			
		||||
            <div class="accordion-body">
 | 
			
		||||
              <div class="row">
 | 
			
		||||
                <div :class="{ 'col-md-8': selectedInterface.Mode !== 'client',  'col-md-12': selectedInterface.Mode !== 'server' }" class="col-md-8">
 | 
			
		||||
                <div class="col-md-8">
 | 
			
		||||
                  <ul>
 | 
			
		||||
                    <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"
 | 
			
		||||
                    <li>{{ $t('modals.peer-view.identifier') }}: {{ selectedPeer.PublicKey }}</li>
 | 
			
		||||
                    <li>{{ $t('modals.peer-view.ip') }}: <span v-for="ip in selectedPeer.Addresses" :key="ip"
 | 
			
		||||
                        class="badge rounded-pill bg-light">{{ ip }}</span></li>
 | 
			
		||||
                    <li v-if="selectedInterface.Mode === 'server'"><strong>{{ $t('modals.peer-view.extra-allowed-ip') }}</strong>: <span v-for="ip in selectedPeer.ExtraAllowedIPs" :key="ip"
 | 
			
		||||
                                                                                                                        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>: {{
 | 
			
		||||
                    <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') }}: {{
 | 
			
		||||
                      selectedPeer.ExpiresAt }}</li>
 | 
			
		||||
                    <li v-if="selectedPeer.Disabled"><strong>{{ $t('modals.peer-view.disabled-status') }}</strong>: {{
 | 
			
		||||
                    <li v-if="selectedPeer.Disabled">{{ $t('modals.peer-view.disabled-status') }}: {{
 | 
			
		||||
                      selectedPeer.DisabledReason }}</li>
 | 
			
		||||
                  </ul>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="col-md-4" v-if="selectedInterface.Mode !== 'client'">
 | 
			
		||||
                <div class="col-md-4">
 | 
			
		||||
                  <img class="config-qr-img" :src="ConfigQrUrl()" loading="lazy" alt="Configuration QR Code">
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -207,7 +199,7 @@ function ConfigQrUrl() {
 | 
			
		|||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div v-if="selectedInterface.Mode !== 'client'" class="accordion-item">
 | 
			
		||||
        <div v-if="selectedInterface.Mode === 'server'" 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">
 | 
			
		||||
| 
						 | 
				
			
			@ -225,9 +217,9 @@ function ConfigQrUrl() {
 | 
			
		|||
    </template>
 | 
			
		||||
    <template #footer>
 | 
			
		||||
      <div class="flex-fill text-start">
 | 
			
		||||
        <button v-if="selectedInterface.Mode !== 'client'" @click.prevent="download" type="button" class="btn btn-primary me-1">{{
 | 
			
		||||
        <button @click.prevent="download" type="button" class="btn btn-primary me-1">{{
 | 
			
		||||
          $t('modals.peer-view.button-download') }}</button>
 | 
			
		||||
        <button v-if="selectedInterface.Mode !== 'client'" @click.prevent="email" type="button" class="btn btn-primary me-1">{{
 | 
			
		||||
        <button @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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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.bigInt()
 | 
			
		||||
    return addr.bigInteger()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Otherwise, assume it's an IPv6 address
 | 
			
		||||
  const addr = new Address6(ip)
 | 
			
		||||
  return addr.bigInt()
 | 
			
		||||
  return addr.bigInteger()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function humanFileSize(size) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -117,7 +117,6 @@
 | 
			
		|||
      "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",
 | 
			
		||||
| 
						 | 
				
			
			@ -221,16 +220,6 @@
 | 
			
		|||
      "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": {
 | 
			
		||||
| 
						 | 
				
			
			@ -472,8 +461,6 @@
 | 
			
		|||
      "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",
 | 
			
		||||
| 
						 | 
				
			
			@ -486,8 +473,6 @@
 | 
			
		|||
      "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"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -42,8 +42,7 @@
 | 
			
		|||
    "audit": "Audit Log",
 | 
			
		||||
    "login": "Login",
 | 
			
		||||
    "logout": "Logout",
 | 
			
		||||
    "keygen": "Key Generator",
 | 
			
		||||
    "calculator": "IP Calculator"
 | 
			
		||||
    "keygen": "Key Generator"
 | 
			
		||||
  },
 | 
			
		||||
  "home": {
 | 
			
		||||
    "headline": "WireGuard® VPN Portal",
 | 
			
		||||
| 
						 | 
				
			
			@ -118,7 +117,6 @@
 | 
			
		|||
      "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",
 | 
			
		||||
| 
						 | 
				
			
			@ -222,16 +220,6 @@
 | 
			
		|||
      "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": {
 | 
			
		||||
| 
						 | 
				
			
			@ -270,26 +258,6 @@
 | 
			
		|||
        "placeholder": "The pre-shared key"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "calculator": {
 | 
			
		||||
    "headline": "WireGuard IP Calculator",
 | 
			
		||||
    "abstract": "Generate a WireGuard Allowed IPs. The IP subnets are generated in your local browser and are never sent to the server.",
 | 
			
		||||
    "headline-allowed-ip": "New Allowed IPs",
 | 
			
		||||
    "button-exclude-private": "Exclude Private IP Ranges",
 | 
			
		||||
    "allowed-ip": {
 | 
			
		||||
        "label": "Allowed IPs",
 | 
			
		||||
        "placeholder": "0.0.0.0/0, ::/0",
 | 
			
		||||
        "empty": "Value cannot be empty"
 | 
			
		||||
    },
 | 
			
		||||
    "dissallowed-ip": {
 | 
			
		||||
        "label": "Disallowed IPs",
 | 
			
		||||
        "placeholder": "10.0.0.0/8, 192.168.0.0/16",
 | 
			
		||||
        "invalid": "Invalid address: {addr}"
 | 
			
		||||
    },
 | 
			
		||||
    "new-allowed-ip": {
 | 
			
		||||
        "label": "Allowed IPs",
 | 
			
		||||
        "placeholder": ""
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "modals": {
 | 
			
		||||
    "user-view": {
 | 
			
		||||
      "headline": "User Account:",
 | 
			
		||||
| 
						 | 
				
			
			@ -494,8 +462,6 @@
 | 
			
		|||
      "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",
 | 
			
		||||
| 
						 | 
				
			
			@ -508,8 +474,6 @@
 | 
			
		|||
      "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"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -72,14 +72,6 @@ const router = createRouter({
 | 
			
		|||
      // this generates a separate chunk (About.[hash].js) for this route
 | 
			
		||||
      // which is lazy-loaded when the route is visited.
 | 
			
		||||
      component: () => import('../views/KeyGeneraterView.vue')
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      path: '/ip-calculator',
 | 
			
		||||
      name: 'ip-calculator',
 | 
			
		||||
      // route level code-splitting
 | 
			
		||||
      // this generates a separate chunk (About.[hash].js) for this route
 | 
			
		||||
      // which is lazy-loaded when the route is visited.
 | 
			
		||||
      component: () => import('../views/IPCalculatorView.vue')
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  linkActiveClass: "active",
 | 
			
		||||
| 
						 | 
				
			
			@ -130,7 +122,7 @@ router.beforeEach(async (to) => {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  // redirect to login page if not logged in and trying to access a restricted page
 | 
			
		||||
  const publicPages = ['/', '/login', '/key-generator', '/ip-calculator']
 | 
			
		||||
  const publicPages = ['/', '/login', '/key-generator']
 | 
			
		||||
  const authRequired = !publicPages.includes(to.path)
 | 
			
		||||
 | 
			
		||||
  if (authRequired && !auth.IsAuthenticated) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -115,7 +115,6 @@ 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,14 +126,9 @@ export const peerStore = defineStore('peers', {
 | 
			
		|||
      if (!statsResponse) {
 | 
			
		||||
        this.stats = {}
 | 
			
		||||
        this.statsEnabled = false
 | 
			
		||||
      } else {
 | 
			
		||||
          this.stats = statsResponse.Stats
 | 
			
		||||
          this.statsEnabled = statsResponse.Enabled
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    async Reset() {
 | 
			
		||||
      this.setPeers([])
 | 
			
		||||
      this.setStats(undefined)
 | 
			
		||||
      this.stats = statsResponse.Stats
 | 
			
		||||
      this.statsEnabled = statsResponse.Enabled
 | 
			
		||||
    },
 | 
			
		||||
    async PreparePeer(interfaceId) {
 | 
			
		||||
      return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/prepare`)
 | 
			
		||||
| 
						 | 
				
			
			@ -191,10 +186,10 @@ export const peerStore = defineStore('peers', {
 | 
			
		|||
    async LoadStats(interfaceId) {
 | 
			
		||||
      // if no interfaceId is given, use the currently selected interface
 | 
			
		||||
      if (!interfaceId) {
 | 
			
		||||
        if (!interfaceStore().GetSelected || !interfaceStore().GetSelected.Identifier) {
 | 
			
		||||
            return // no interface, nothing to load
 | 
			
		||||
        }
 | 
			
		||||
        interfaceId = interfaceStore().GetSelected.Identifier
 | 
			
		||||
        if (!interfaceId) {
 | 
			
		||||
          return // no interface, nothing to load
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      this.fetching = true
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -265,10 +260,10 @@ export const peerStore = defineStore('peers', {
 | 
			
		|||
    async LoadPeers(interfaceId) {
 | 
			
		||||
      // if no interfaceId is given, use the currently selected interface
 | 
			
		||||
      if (!interfaceId) {
 | 
			
		||||
        if (!interfaceStore().GetSelected || !interfaceStore().GetSelected.Identifier) {
 | 
			
		||||
        interfaceId = interfaceStore().GetSelected.Identifier
 | 
			
		||||
        if (!interfaceId) {
 | 
			
		||||
          return // no interface, nothing to load
 | 
			
		||||
        }
 | 
			
		||||
        interfaceId = interfaceStore().GetSelected.Identifier
 | 
			
		||||
      }
 | 
			
		||||
      this.fetching = true
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -151,17 +151,6 @@ 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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,139 +0,0 @@
 | 
			
		|||
<script setup>
 | 
			
		||||
 | 
			
		||||
import {ref, watch, computed} from "vue";
 | 
			
		||||
import isCidr from "is-cidr";
 | 
			
		||||
import {isIP} from "is-ip";
 | 
			
		||||
import {excludeCidr} from "cidr-tools";
 | 
			
		||||
import {useI18n} from 'vue-i18n';
 | 
			
		||||
 | 
			
		||||
const allowedIp = ref("")
 | 
			
		||||
const dissallowedIp = ref("")
 | 
			
		||||
const privateIP = ref("10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16")
 | 
			
		||||
 | 
			
		||||
const {t} = useI18n()
 | 
			
		||||
 | 
			
		||||
const errorAllowed = ref("")
 | 
			
		||||
const errorDissallowed = ref("")
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validate a comma-separated list of IP and/or CIDR addresses.
 | 
			
		||||
 * @function validateIpAndCidrList
 | 
			
		||||
 * @param {string} value - Comma-separated string (e.g. "10.0.0.0/8, 192.168.0.1")
 | 
			
		||||
 * @returns {true|string} Returns true if all values are valid, otherwise an error message.
 | 
			
		||||
 */
 | 
			
		||||
function validateIpAndCidrList(value) {
 | 
			
		||||
  const list = value.split(",").map(v => v.trim()).filter(Boolean);
 | 
			
		||||
  if (list.length === 0) { 
 | 
			
		||||
    return t('calculator.allowed-ip.empty');
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  for (const addr of list) {
 | 
			
		||||
    if (!isIP(addr) && !isCidr(addr)) {
 | 
			
		||||
      return t('calculator.dissallowed-ip.invalid', {addr});
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Watcher that validates allowed IPs input in real-time.
 | 
			
		||||
 * Updates `errorAllowed` whenever `allowedIp` changes.
 | 
			
		||||
 */
 | 
			
		||||
watch(allowedIp, (newValue) => {
 | 
			
		||||
  const result = validateIpAndCidrList(newValue);
 | 
			
		||||
  errorAllowed.value = result === true ? "" : result;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Watcher that validates disallowed IPs input in real-time.
 | 
			
		||||
 * Updates `errorDissallowed` whenever `dissallowedIp` changes.
 | 
			
		||||
 */
 | 
			
		||||
watch(dissallowedIp, (newValue) => {
 | 
			
		||||
  if (!allowedIp.value || allowedIp.value.trim() === "") {
 | 
			
		||||
    allowedIp.value = "0.0.0.0/0";
 | 
			
		||||
  }
 | 
			
		||||
  const result = validateIpAndCidrList(newValue);
 | 
			
		||||
  errorDissallowed.value = result === true ? "" : result;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Dynamically computes the resulting "Allowed IPs" list
 | 
			
		||||
 * by excluding the disallowed ranges from the allowed ranges.
 | 
			
		||||
 * @constant
 | 
			
		||||
 * @type {ComputedRef<string>}
 | 
			
		||||
 * @returns {string} A comma-separated string of resulting CIDR blocks.
 | 
			
		||||
 */
 | 
			
		||||
const newAllowedIp = computed(() => {
 | 
			
		||||
  if (errorAllowed.value || errorDissallowed.value) return "";
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const allowedList = allowedIp.value.split(",").map(v => v.trim()).filter(Boolean);
 | 
			
		||||
    const disallowedList = dissallowedIp.value.split(",").map(v => v.trim()).filter(Boolean);
 | 
			
		||||
 | 
			
		||||
    const result = excludeCidr(allowedList, disallowedList);
 | 
			
		||||
 | 
			
		||||
    return result.join(", ");
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    console.error("Allowed IPs calculation error:", e);
 | 
			
		||||
    return "";
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Append private IP ranges to disallowed IPs.
 | 
			
		||||
 * If any already exist, they are preserved and new ones are appended only if not present.
 | 
			
		||||
 * @function addPrivateIPs
 | 
			
		||||
 */
 | 
			
		||||
function addPrivateIPs() {
 | 
			
		||||
  const privateList = privateIP.value.split(",").map(v => v.trim());
 | 
			
		||||
  const currentList = dissallowedIp.value.split(",").map(v => v.trim()).filter(Boolean);
 | 
			
		||||
 | 
			
		||||
  const combined = Array.from(new Set([...currentList, ...privateList]));
 | 
			
		||||
  dissallowedIp.value = combined.join(", ");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="page-header">
 | 
			
		||||
    <h1>{{ $t('calculator.headline') }}</h1>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <p class="lead">{{ $t('calculator.abstract') }}</p>
 | 
			
		||||
 | 
			
		||||
  <div class="mt-4 row">
 | 
			
		||||
    <div class="col-12 col-lg-5">
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <div class="form-group">
 | 
			
		||||
          <label class="form-label mt-4">{{ $t('calculator.allowed-ip.label') }}</label>
 | 
			
		||||
          <input class="form-control" v-model="allowedIp" :placeholder="$t('calculator.allowed-ip.placeholder')" :class="{ 'is-invalid': errorAllowed }">
 | 
			
		||||
          <div v-if="errorAllowed" class="text-danger mt-1">{{ errorAllowed }}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="form-group">
 | 
			
		||||
          <label class="form-label mt-4">{{ $t('calculator.dissallowed-ip.label') }}</label>
 | 
			
		||||
          <input class="form-control" v-model="dissallowedIp" :placeholder="$t('calculator.dissallowed-ip.placeholder')" :class="{ 'is-invalid': errorDissallowed }">
 | 
			
		||||
          <div v-if="errorDissallowed" class="text-danger mt-1">{{ errorDissallowed }}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <hr class="mt-4">
 | 
			
		||||
        <button class="btn btn-primary mb-4" type="button" @click="addPrivateIPs">{{ $t('calculator.button-exclude-private') }}</button>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="col-12 col-lg-2 mt-sm-4">
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="col-12 col-lg-5">
 | 
			
		||||
      <h1>{{ $t('calculator.headline-allowed-ip') }}</h1>
 | 
			
		||||
      <fieldset>
 | 
			
		||||
        <div class="form-group">
 | 
			
		||||
          <textarea class="form-control" :value="newAllowedIp" rows="6" :placeholder="$t('calculator.new-allowed-ip.placeholder')" readonly></textarea>
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
 | 
			
		||||
</style>
 | 
			
		||||
| 
						 | 
				
			
			@ -218,12 +218,12 @@ onMounted(async () => {
 | 
			
		|||
                  <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.mtu') }}:</td>
 | 
			
		||||
                  <td>{{interfaces.GetSelected.Mtu}}</td>
 | 
			
		||||
                  <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.default-dns') }}:</td>
 | 
			
		||||
                  <td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.PeerDefDns" :key="addr">{{addr}}</span></td>
 | 
			
		||||
                  <td>{{ $t('interfaces.interface.mtu') }}:</td>
 | 
			
		||||
                  <td>{{interfaces.GetSelected.Mtu}}</td>
 | 
			
		||||
                </tr>
 | 
			
		||||
                <tr>
 | 
			
		||||
                  <td>{{ $t('interfaces.interface.default-keep-alive') }}:</td>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,9 +1,8 @@
 | 
			
		|||
<script setup>
 | 
			
		||||
import {computed, onMounted, ref} from "vue";
 | 
			
		||||
import {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()
 | 
			
		||||
| 
						 | 
				
			
			@ -35,45 +34,6 @@ 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>
 | 
			
		||||
| 
						 | 
				
			
			@ -83,45 +43,52 @@ const updatePassword = async () => {
 | 
			
		|||
 | 
			
		||||
  <p class="lead">{{ $t('settings.abstract') }}</p>
 | 
			
		||||
 | 
			
		||||
  <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 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="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 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="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 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="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 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>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -206,53 +173,4 @@ const updatePassword = async () => {
 | 
			
		|||
    </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.16.0
 | 
			
		||||
	github.com/coreos/go-oidc/v3 v3.15.0
 | 
			
		||||
	github.com/glebarez/sqlite v1.11.0
 | 
			
		||||
	github.com/go-ldap/ldap/v3 v3.4.12
 | 
			
		||||
	github.com/go-ldap/ldap/v3 v3.4.11
 | 
			
		||||
	github.com/go-pkgz/routegroup v1.5.3
 | 
			
		||||
	github.com/go-playground/validator/v10 v10.28.0
 | 
			
		||||
	github.com/go-playground/validator/v10 v10.27.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.43.0
 | 
			
		||||
	golang.org/x/oauth2 v0.32.0
 | 
			
		||||
	golang.org/x/sys v0.37.0
 | 
			
		||||
	golang.org/x/crypto v0.42.0
 | 
			
		||||
	golang.org/x/oauth2 v0.31.0
 | 
			
		||||
	golang.org/x/sys v0.36.0
 | 
			
		||||
	golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
 | 
			
		||||
	gopkg.in/yaml.v3 v3.0.1
 | 
			
		||||
	gorm.io/driver/mysql v1.6.0
 | 
			
		||||
| 
						 | 
				
			
			@ -44,17 +44,22 @@ 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.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-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-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
 | 
			
		||||
| 
						 | 
				
			
			@ -64,14 +69,16 @@ 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.6 // indirect
 | 
			
		||||
	github.com/google/go-tpm v0.9.5 // indirect
 | 
			
		||||
	github.com/jackc/pgpassfile v1.0.0 // indirect
 | 
			
		||||
	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
 | 
			
		||||
	github.com/jackc/pgx/v5 v5.7.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
 | 
			
		||||
| 
						 | 
				
			
			@ -89,19 +96,18 @@ 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.3 // indirect
 | 
			
		||||
	go.yaml.in/yaml/v3 v3.0.4 // indirect
 | 
			
		||||
	golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
 | 
			
		||||
	go.yaml.in/yaml/v2 v2.4.2 // indirect
 | 
			
		||||
	golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
 | 
			
		||||
	golang.org/x/mod v0.28.0 // indirect
 | 
			
		||||
	golang.org/x/net v0.45.0 // indirect
 | 
			
		||||
	golang.org/x/net v0.43.0 // indirect
 | 
			
		||||
	golang.org/x/sync v0.17.0 // indirect
 | 
			
		||||
	golang.org/x/text v0.30.0 // indirect
 | 
			
		||||
	golang.org/x/tools v0.37.0 // indirect
 | 
			
		||||
	golang.org/x/text v0.29.0 // indirect
 | 
			
		||||
	golang.org/x/tools v0.36.0 // indirect
 | 
			
		||||
	golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
 | 
			
		||||
	google.golang.org/protobuf v1.36.10 // indirect
 | 
			
		||||
	modernc.org/libc v1.66.10 // indirect
 | 
			
		||||
	google.golang.org/protobuf v1.36.8 // indirect
 | 
			
		||||
	modernc.org/libc v1.66.8 // indirect
 | 
			
		||||
	modernc.org/mathutil v1.7.1 // indirect
 | 
			
		||||
	modernc.org/memory v1.11.0 // indirect
 | 
			
		||||
	modernc.org/sqlite v1.39.0 // indirect
 | 
			
		||||
	modernc.org/sqlite v1.38.2 // 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-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
 | 
			
		||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 | 
			
		||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
 | 
			
		||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 | 
			
		||||
github.com/alexedwards/scs/v2 v2.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.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
 | 
			
		||||
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
 | 
			
		||||
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
 | 
			
		||||
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
 | 
			
		||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 | 
			
		||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 | 
			
		||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 | 
			
		||||
| 
						 | 
				
			
			@ -58,33 +58,40 @@ 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.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-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-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=
 | 
			
		||||
| 
						 | 
				
			
			@ -93,8 +100,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.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
 | 
			
		||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
 | 
			
		||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
 | 
			
		||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
 | 
			
		||||
github.com/go-sql-driver/mysql v1.9.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=
 | 
			
		||||
| 
						 | 
				
			
			@ -115,8 +122,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.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
 | 
			
		||||
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
 | 
			
		||||
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
 | 
			
		||||
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
 | 
			
		||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
 | 
			
		||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
 | 
			
		||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 | 
			
		||||
| 
						 | 
				
			
			@ -151,6 +158,8 @@ 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=
 | 
			
		||||
| 
						 | 
				
			
			@ -164,6 +173,8 @@ 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=
 | 
			
		||||
| 
						 | 
				
			
			@ -246,10 +257,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.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=
 | 
			
		||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
 | 
			
		||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
 | 
			
		||||
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
 | 
			
		||||
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 | 
			
		||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
 | 
			
		||||
| 
						 | 
				
			
			@ -262,10 +273,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.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/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
 | 
			
		||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
 | 
			
		||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
 | 
			
		||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
 | 
			
		||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 | 
			
		||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 | 
			
		||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 | 
			
		||||
| 
						 | 
				
			
			@ -291,10 +302,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.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/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/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=
 | 
			
		||||
| 
						 | 
				
			
			@ -324,8 +335,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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
 | 
			
		||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 | 
			
		||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
 | 
			
		||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 | 
			
		||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 | 
			
		||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 | 
			
		||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 | 
			
		||||
| 
						 | 
				
			
			@ -354,23 +365,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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
 | 
			
		||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
 | 
			
		||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
 | 
			
		||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
 | 
			
		||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 | 
			
		||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 | 
			
		||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 | 
			
		||||
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.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
 | 
			
		||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
 | 
			
		||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
 | 
			
		||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
 | 
			
		||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 | 
			
		||||
golang.zx2c4.com/wireguard v0.0.0-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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
 | 
			
		||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 | 
			
		||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
 | 
			
		||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
 | 
			
		||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 | 
			
		||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 | 
			
		||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 | 
			
		||||
| 
						 | 
				
			
			@ -390,18 +401,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.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
 | 
			
		||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
 | 
			
		||||
modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s=
 | 
			
		||||
modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
 | 
			
		||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
 | 
			
		||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
 | 
			
		||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
 | 
			
		||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
 | 
			
		||||
modernc.org/fileutil v1.3.28 h1:Vp156KUA2nPu9F1NEv036x9UGOjg2qsi5QlWTjZmtMk=
 | 
			
		||||
modernc.org/fileutil v1.3.28/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
 | 
			
		||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
 | 
			
		||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
 | 
			
		||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
 | 
			
		||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
 | 
			
		||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
 | 
			
		||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
 | 
			
		||||
modernc.org/libc v1.66.8 h1:/awsvTnyN/sNjvJm6S3lb7KZw5WV4ly/sBEG7ZUzmIE=
 | 
			
		||||
modernc.org/libc v1.66.8/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k=
 | 
			
		||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
 | 
			
		||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
 | 
			
		||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
 | 
			
		||||
| 
						 | 
				
			
			@ -410,8 +421,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.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
 | 
			
		||||
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
 | 
			
		||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
 | 
			
		||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
 | 
			
		||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
 | 
			
		||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
 | 
			
		||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,7 +9,6 @@ import (
 | 
			
		|||
	"log/slog"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/exec"
 | 
			
		||||
	"slices"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -85,8 +84,8 @@ func NewLocalController(cfg *config.Config) (*LocalController, error) {
 | 
			
		|||
		wg: wg,
 | 
			
		||||
		nl: nl,
 | 
			
		||||
 | 
			
		||||
		shellCmd:              "bash",                            // we only support bash at the moment
 | 
			
		||||
		resolvConfIfacePrefix: cfg.Backend.LocalResolvconfPrefix, // WireGuard interfaces have a tun. prefix in resolvconf
 | 
			
		||||
		shellCmd:              "bash", // we only support bash at the moment
 | 
			
		||||
		resolvConfIfacePrefix: "tun.", // WireGuard interfaces have a tun. prefix in resolvconf
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return repo, nil
 | 
			
		||||
| 
						 | 
				
			
			@ -547,11 +546,7 @@ func (c LocalController) deletePeer(deviceId domain.InterfaceIdentifier, id doma
 | 
			
		|||
 | 
			
		||||
// region wg-quick-related
 | 
			
		||||
 | 
			
		||||
func (c LocalController) ExecuteInterfaceHook(
 | 
			
		||||
	_ context.Context,
 | 
			
		||||
	id domain.InterfaceIdentifier,
 | 
			
		||||
	hookCmd string,
 | 
			
		||||
) error {
 | 
			
		||||
func (c LocalController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error {
 | 
			
		||||
	if hookCmd == "" {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -565,7 +560,7 @@ func (c LocalController) ExecuteInterfaceHook(
 | 
			
		|||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c LocalController) SetDNS(_ context.Context, id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error {
 | 
			
		||||
func (c LocalController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error {
 | 
			
		||||
	if dnsStr == "" && dnsSearchStr == "" {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -594,7 +589,7 @@ func (c LocalController) SetDNS(_ context.Context, id domain.InterfaceIdentifier
 | 
			
		|||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c LocalController) UnsetDNS(_ context.Context, id domain.InterfaceIdentifier, _, _ string) error {
 | 
			
		||||
func (c LocalController) UnsetDNS(id domain.InterfaceIdentifier) error {
 | 
			
		||||
	dnsCommand := "resolvconf -d %resPref%i -f"
 | 
			
		||||
 | 
			
		||||
	err := c.exec(dnsCommand, id)
 | 
			
		||||
| 
						 | 
				
			
			@ -616,7 +611,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+"\n"); err != nil {
 | 
			
		||||
			if _, err := fmt.Fprint(b, ln); err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
| 
						 | 
				
			
			@ -624,8 +619,6 @@ 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",
 | 
			
		||||
| 
						 | 
				
			
			@ -638,116 +631,49 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
 | 
			
		|||
 | 
			
		||||
// region routing-related
 | 
			
		||||
 | 
			
		||||
// 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)
 | 
			
		||||
func (c LocalController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error {
 | 
			
		||||
	// update fwmark rules
 | 
			
		||||
	if err := c.setFwMarkRules(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)
 | 
			
		||||
		}
 | 
			
		||||
	// update main rule
 | 
			
		||||
	if err := c.setMainRule(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)
 | 
			
		||||
	// cleanup old main rules
 | 
			
		||||
	if err := c.cleanupMainRule(rules); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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,
 | 
			
		||||
		})
 | 
			
		||||
func (c LocalController) setFwMarkRules(rules []domain.RouteRule) error {
 | 
			
		||||
	for _, rule := range rules {
 | 
			
		||||
		existingRules, err := c.nl.RuleList(int(rule.IpFamily))
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to add/update route %s on table %d for interface %s: %w",
 | 
			
		||||
				cidr.String(), table, interfaceId, err)
 | 
			
		||||
			return fmt.Errorf("failed to get existing rules for family %s: %w", rule.IpFamily, err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 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")
 | 
			
		||||
		ruleExists := false
 | 
			
		||||
		for _, existingRule := range existingRules {
 | 
			
		||||
			if rule.FwMark == existingRule.Mark && rule.Table == existingRule.Table {
 | 
			
		||||
				ruleExists = true
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
			rawRoute.Dst = netlinkAddr.IpNet()
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		route := domain.CidrFromIpNet(*rawRoute.Dst)
 | 
			
		||||
		if slices.Contains(cidrs, route) {
 | 
			
		||||
			continue
 | 
			
		||||
		if ruleExists {
 | 
			
		||||
			continue // rule already exists, no need to recreate it
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		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 {
 | 
			
		||||
		// create a missing rule
 | 
			
		||||
		if err := c.nl.RuleAdd(&netlink.Rule{
 | 
			
		||||
			Family:            family,
 | 
			
		||||
			Table:             table,
 | 
			
		||||
			Mark:              fwMark,
 | 
			
		||||
			Family:            int(rule.IpFamily),
 | 
			
		||||
			Table:             rule.Table,
 | 
			
		||||
			Mark:              rule.FwMark,
 | 
			
		||||
			Invert:            true,
 | 
			
		||||
			SuppressIfgroup:   -1,
 | 
			
		||||
			SuppressPrefixlen: -1,
 | 
			
		||||
| 
						 | 
				
			
			@ -756,102 +682,15 @@ func (c LocalController) setRoutesForFamily(
 | 
			
		|||
			Goto:              -1,
 | 
			
		||||
			Flow:              -1,
 | 
			
		||||
		}); err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to setup rule for fwmark %d and table %d for family-id %d: %w",
 | 
			
		||||
				fwMark, table, family, err)
 | 
			
		||||
			return fmt.Errorf("failed to setup %s rule for fwmark %d and table %d: %w",
 | 
			
		||||
				rule.IpFamily, rule.FwMark, rule.Table, 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 prio of 32766
 | 
			
		||||
	prio := 32700 // linux main rule has a priority of 32766
 | 
			
		||||
	for {
 | 
			
		||||
		isFresh := true
 | 
			
		||||
		for _, existingRule := range existingRules {
 | 
			
		||||
| 
						 | 
				
			
			@ -869,145 +708,126 @@ func (c LocalController) getRulePriority(existingRules []netlink.Rule) int {
 | 
			
		|||
	return prio
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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)
 | 
			
		||||
 | 
			
		||||
	wgDev, err := c.wg.Device(string(interfaceId))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		slog.Debug("wg device already removed, route cleanup might be incomplete", "interface", interfaceId)
 | 
			
		||||
		wgDev = nil
 | 
			
		||||
	}
 | 
			
		||||
	link, err := c.nl.LinkByName(string(interfaceId))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		slog.Debug("physical link already removed, route cleanup might be incomplete", "interface", interfaceId)
 | 
			
		||||
		link = nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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)
 | 
			
		||||
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
 | 
			
		||||
		}
 | 
			
		||||
		err = c.removeRoutesForFamily(interfaceId, link, netlink.FAMILY_V6, realTable, realFwMark, cidrsV6)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to remove v6 routes: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	if !shouldHaveMainRule {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	existingRules, err := c.nl.RuleList(int(family))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to get existing rules for family %s: %w", family, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ruleExists := false
 | 
			
		||||
	for _, existingRule := range existingRules {
 | 
			
		||||
		if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
 | 
			
		||||
			ruleExists = true
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if 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)
 | 
			
		||||
	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
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		err = c.removeRouteRulesForTable(netlink.FAMILY_V6, realTable)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to remove v6 route rules for %s: %w", interfaceId, err)
 | 
			
		||||
		if isFresh {
 | 
			
		||||
			break
 | 
			
		||||
		} else {
 | 
			
		||||
			priority++
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return priority
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c LocalController) cleanupMainRule(rules []domain.RouteRule) error {
 | 
			
		||||
	var family domain.IpFamily
 | 
			
		||||
	for _, rule := range rules {
 | 
			
		||||
		family = rule.IpFamily
 | 
			
		||||
		break
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	existingRules, err := c.nl.RuleList(int(family))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to get existing rules for family %s: %w", family, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	shouldHaveMainRule := false
 | 
			
		||||
	for _, rule := range rules {
 | 
			
		||||
		if rule.HasDefault == true {
 | 
			
		||||
			shouldHaveMainRule = true
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	mainRules := 0
 | 
			
		||||
	for _, existingRule := range existingRules {
 | 
			
		||||
		if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
 | 
			
		||||
			mainRules++
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	removalCount := 0
 | 
			
		||||
	if mainRules > 1 {
 | 
			
		||||
		removalCount = mainRules - 1 // we only want one single rule
 | 
			
		||||
	}
 | 
			
		||||
	if !shouldHaveMainRule {
 | 
			
		||||
		removalCount = mainRules
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, existingRule := range existingRules {
 | 
			
		||||
		if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
 | 
			
		||||
			if removalCount > 0 {
 | 
			
		||||
				existingRule.Family = int(family) // set family, somehow the RuleList method does not populate the family field
 | 
			
		||||
				if err := c.nl.RuleDel(&existingRule); err != nil {
 | 
			
		||||
					return fmt.Errorf("failed to delete main rule: %w", err)
 | 
			
		||||
				}
 | 
			
		||||
				removalCount--
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c LocalController) 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
 | 
			
		||||
func (c LocalController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error {
 | 
			
		||||
	// TODO implement me
 | 
			
		||||
	panic("implement me")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// endregion routing-related
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,9 +15,6 @@ import (
 | 
			
		|||
	"github.com/h44z/wg-portal/internal/lowlevel"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const MikrotikRouteDistance = 5
 | 
			
		||||
const MikrotikDefaultRoutingTable = "main"
 | 
			
		||||
 | 
			
		||||
type MikrotikController struct {
 | 
			
		||||
	coreCfg *config.Config
 | 
			
		||||
	cfg     *config.BackendMikrotik
 | 
			
		||||
| 
						 | 
				
			
			@ -25,9 +22,8 @@ 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
 | 
			
		||||
	coreMutex        sync.Mutex // for updating the core configuration such as routing table or DNS settings
 | 
			
		||||
	interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*sync.Mutex
 | 
			
		||||
	peerMutexes      sync.Map // map[domain.PeerIdentifier]*sync.Mutex
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikController, error) {
 | 
			
		||||
| 
						 | 
				
			
			@ -44,7 +40,6 @@ func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik)
 | 
			
		|||
 | 
			
		||||
		interfaceMutexes: sync.Map{},
 | 
			
		||||
		peerMutexes:      sync.Map{},
 | 
			
		||||
		coreMutex:        sync.Mutex{},
 | 
			
		||||
	}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -768,404 +763,33 @@ func (c *MikrotikController) DeletePeer(
 | 
			
		|||
 | 
			
		||||
// region wg-quick-related
 | 
			
		||||
 | 
			
		||||
func (c *MikrotikController) ExecuteInterfaceHook(
 | 
			
		||||
	_ context.Context,
 | 
			
		||||
	_ domain.InterfaceIdentifier,
 | 
			
		||||
	_ string,
 | 
			
		||||
) error {
 | 
			
		||||
func (c *MikrotikController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error {
 | 
			
		||||
	// TODO implement me
 | 
			
		||||
	slog.Error("interface hooks are not yet supported for Mikrotik backends, please open an issue on GitHub")
 | 
			
		||||
	return nil
 | 
			
		||||
	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) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) 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
 | 
			
		||||
func (c *MikrotikController) UnsetDNS(id domain.InterfaceIdentifier) error {
 | 
			
		||||
	// TODO implement me
 | 
			
		||||
	panic("implement me")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// endregion wg-quick-related
 | 
			
		||||
 | 
			
		||||
// region routing-related
 | 
			
		||||
 | 
			
		||||
// 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) SyncRouteRules(_ 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
 | 
			
		||||
func (c *MikrotikController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error {
 | 
			
		||||
	// TODO implement me
 | 
			
		||||
	panic("implement me")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// endregion routing-related
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,113 @@
 | 
			
		|||
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,38 +1550,6 @@
 | 
			
		|||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/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": [
 | 
			
		||||
| 
						 | 
				
			
			@ -2191,10 +2159,6 @@
 | 
			
		|||
                        }
 | 
			
		||||
                    ]
 | 
			
		||||
                },
 | 
			
		||||
                "UserDisplayName": {
 | 
			
		||||
                    "description": "the owner display name",
 | 
			
		||||
                    "type": "string"
 | 
			
		||||
                },
 | 
			
		||||
                "UserIdentifier": {
 | 
			
		||||
                    "description": "the owner",
 | 
			
		||||
                    "type": "string"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -322,9 +322,6 @@ 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
 | 
			
		||||
| 
						 | 
				
			
			@ -1445,27 +1442,6 @@ 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,6 +17,11 @@
 | 
			
		|||
    "paths": {
 | 
			
		||||
        "/interface/all": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
                ],
 | 
			
		||||
| 
						 | 
				
			
			@ -47,16 +52,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/interface/by-id/{id}": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
                ],
 | 
			
		||||
| 
						 | 
				
			
			@ -105,14 +110,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"
 | 
			
		||||
| 
						 | 
				
			
			@ -177,14 +182,14 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            "delete": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
            },
 | 
			
		||||
            "delete": {
 | 
			
		||||
                ],
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
                ],
 | 
			
		||||
| 
						 | 
				
			
			@ -236,16 +241,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/interface/new": {
 | 
			
		||||
            "post": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "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"
 | 
			
		||||
| 
						 | 
				
			
			@ -303,16 +308,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/interface/prepare": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).",
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
| 
						 | 
				
			
			@ -347,16 +352,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/metrics/by-interface/{id}": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
                ],
 | 
			
		||||
| 
						 | 
				
			
			@ -405,16 +410,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/metrics/by-peer/{id}": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
                ],
 | 
			
		||||
| 
						 | 
				
			
			@ -463,16 +468,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/metrics/by-user/{id}": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
                ],
 | 
			
		||||
| 
						 | 
				
			
			@ -521,16 +526,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/peer/by-id/{id}": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "description": "Normal users can only access their own records. Admins can access all records.",
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
| 
						 | 
				
			
			@ -580,14 +585,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"
 | 
			
		||||
| 
						 | 
				
			
			@ -652,14 +657,14 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            "delete": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
            },
 | 
			
		||||
            "delete": {
 | 
			
		||||
                ],
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
                ],
 | 
			
		||||
| 
						 | 
				
			
			@ -711,16 +716,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/peer/by-interface/{id}": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
                ],
 | 
			
		||||
| 
						 | 
				
			
			@ -760,16 +765,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/peer/by-user/{id}": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "description": "Normal users can only access their own records. Admins can access all records.",
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
| 
						 | 
				
			
			@ -810,16 +815,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/peer/new": {
 | 
			
		||||
            "post": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "description": "Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
| 
						 | 
				
			
			@ -877,16 +882,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/peer/prepare/{id}": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "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"
 | 
			
		||||
| 
						 | 
				
			
			@ -942,16 +947,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/provisioning/data/peer-config": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "description": "Normal users can only access their own record. Admins can access all records.",
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "text/plain",
 | 
			
		||||
| 
						 | 
				
			
			@ -1008,16 +1013,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/provisioning/data/peer-qr": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "description": "Normal users can only access their own record. Admins can access all records.",
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "image/png",
 | 
			
		||||
| 
						 | 
				
			
			@ -1074,16 +1079,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/provisioning/data/user-info": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "description": "Normal users can only access their own record. Admins can access all records.",
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
| 
						 | 
				
			
			@ -1144,16 +1149,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/provisioning/new-peer": {
 | 
			
		||||
            "post": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "description": "Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.",
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
| 
						 | 
				
			
			@ -1211,16 +1216,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/user/all": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
                ],
 | 
			
		||||
| 
						 | 
				
			
			@ -1251,16 +1256,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/user/by-id/{id}": {
 | 
			
		||||
            "get": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "description": "Normal users can only access their own record. Admins can access all records.",
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
| 
						 | 
				
			
			@ -1310,14 +1315,14 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            "put": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
            },
 | 
			
		||||
            "put": {
 | 
			
		||||
                ],
 | 
			
		||||
                "description": "Only admins can update existing records.",
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
| 
						 | 
				
			
			@ -1382,14 +1387,14 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            "delete": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
            },
 | 
			
		||||
            "delete": {
 | 
			
		||||
                ],
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
                ],
 | 
			
		||||
| 
						 | 
				
			
			@ -1441,16 +1446,16 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "/user/new": {
 | 
			
		||||
            "post": {
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "description": "Only admins can create new records.",
 | 
			
		||||
                "produces": [
 | 
			
		||||
                    "application/json"
 | 
			
		||||
| 
						 | 
				
			
			@ -1508,12 +1513,7 @@
 | 
			
		|||
                            "$ref": "#/definitions/models.Error"
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                "security": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "BasicAuth": []
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,8 +2,6 @@ package backend
 | 
			
		|||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/h44z/wg-portal/internal/config"
 | 
			
		||||
	"github.com/h44z/wg-portal/internal/domain"
 | 
			
		||||
| 
						 | 
				
			
			@ -72,44 +70,6 @@ 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,8 +28,6 @@ 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.
 | 
			
		||||
| 
						 | 
				
			
			@ -77,7 +75,6 @@ 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.
 | 
			
		||||
| 
						 | 
				
			
			@ -394,68 +391,3 @@ 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))
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,16 +19,15 @@ 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
 | 
			
		||||
	sensitiveInfoLogging bool
 | 
			
		||||
	allowedDomains       []string
 | 
			
		||||
	name                string
 | 
			
		||||
	cfg                 *oauth2.Config
 | 
			
		||||
	userInfoEndpoint    string
 | 
			
		||||
	client              *http.Client
 | 
			
		||||
	userInfoMapping     config.OauthFields
 | 
			
		||||
	userAdminMapping    *config.OauthAdminMapping
 | 
			
		||||
	registrationEnabled bool
 | 
			
		||||
	userInfoLogging     bool
 | 
			
		||||
	allowedDomains      []string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newPlainOauthAuthenticator(
 | 
			
		||||
| 
						 | 
				
			
			@ -58,7 +57,6 @@ 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
 | 
			
		||||
| 
						 | 
				
			
			@ -112,10 +110,6 @@ 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)
 | 
			
		||||
| 
						 | 
				
			
			@ -127,15 +121,11 @@ 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 debug",
 | 
			
		||||
		slog.Debug("OAuth user info",
 | 
			
		||||
			"source", p.name,
 | 
			
		||||
			"info", string(contents))
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,16 +16,15 @@ 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
 | 
			
		||||
	sensitiveInfoLogging bool
 | 
			
		||||
	allowedDomains       []string
 | 
			
		||||
	name                string
 | 
			
		||||
	provider            *oidc.Provider
 | 
			
		||||
	verifier            *oidc.IDTokenVerifier
 | 
			
		||||
	cfg                 *oauth2.Config
 | 
			
		||||
	userInfoMapping     config.OauthFields
 | 
			
		||||
	userAdminMapping    *config.OauthAdminMapping
 | 
			
		||||
	registrationEnabled bool
 | 
			
		||||
	userInfoLogging     bool
 | 
			
		||||
	allowedDomains      []string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newOidcAuthenticator(
 | 
			
		||||
| 
						 | 
				
			
			@ -59,7 +58,6 @@ 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
 | 
			
		||||
| 
						 | 
				
			
			@ -104,40 +102,24 @@ 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 debug",
 | 
			
		||||
		slog.Debug("OIDC user info",
 | 
			
		||||
			"source", o.name,
 | 
			
		||||
			"info", string(contents))
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,6 @@ import (
 | 
			
		|||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"net/mail"
 | 
			
		||||
 | 
			
		||||
	"github.com/h44z/wg-portal/internal/config"
 | 
			
		||||
	"github.com/h44z/wg-portal/internal/domain"
 | 
			
		||||
| 
						 | 
				
			
			@ -102,15 +101,29 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, style string,
 | 
			
		|||
		}
 | 
			
		||||
 | 
			
		||||
		if peer.UserIdentifier == "" {
 | 
			
		||||
			return fmt.Errorf("peer %s has no user linked, no email is sent", peerId)
 | 
			
		||||
			slog.Debug("skipping peer email",
 | 
			
		||||
				"peer", peerId,
 | 
			
		||||
				"reason", "no user linked")
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		email, user := m.resolveEmail(ctx, peer)
 | 
			
		||||
		if email == "" {
 | 
			
		||||
			return fmt.Errorf("peer %s has no valid email address, 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
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		err = m.sendPeerEmail(ctx, linkOnly, style, &user, peer)
 | 
			
		||||
		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)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to send peer email for %s: %w", peerId, err)
 | 
			
		||||
		}
 | 
			
		||||
| 
						 | 
				
			
			@ -181,37 +194,3 @@ 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,23 +4,25 @@ import (
 | 
			
		|||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"sync"
 | 
			
		||||
 | 
			
		||||
	"github.com/vishvananda/netlink"
 | 
			
		||||
	"golang.org/x/sys/unix"
 | 
			
		||||
	"golang.zx2c4.com/wireguard/wgctrl"
 | 
			
		||||
	"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
 | 
			
		||||
 | 
			
		||||
	"github.com/h44z/wg-portal/internal/app"
 | 
			
		||||
	"github.com/h44z/wg-portal/internal/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 {
 | 
			
		||||
	// GetInterface returns the interface with the given identifier.
 | 
			
		||||
	GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error)
 | 
			
		||||
	// 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)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type EventBus interface {
 | 
			
		||||
| 
						 | 
				
			
			@ -28,13 +30,6 @@ 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 {
 | 
			
		||||
| 
						 | 
				
			
			@ -50,27 +45,28 @@ type routeRuleInfo struct {
 | 
			
		|||
type Manager struct {
 | 
			
		||||
	cfg *config.Config
 | 
			
		||||
 | 
			
		||||
	bus          EventBus
 | 
			
		||||
	db           InterfaceAndPeerDatabaseRepo
 | 
			
		||||
	wgController ControllerManager
 | 
			
		||||
 | 
			
		||||
	mux *sync.Mutex
 | 
			
		||||
	bus EventBus
 | 
			
		||||
	wg  lowlevel.WireGuardClient
 | 
			
		||||
	nl  lowlevel.NetlinkClient
 | 
			
		||||
	db  InterfaceAndPeerDatabaseRepo
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewRouteManager creates a new route manager instance.
 | 
			
		||||
func NewRouteManager(
 | 
			
		||||
	cfg *config.Config,
 | 
			
		||||
	bus EventBus,
 | 
			
		||||
	db InterfaceAndPeerDatabaseRepo,
 | 
			
		||||
	wgController ControllerManager,
 | 
			
		||||
) (*Manager, error) {
 | 
			
		||||
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{}
 | 
			
		||||
 | 
			
		||||
	m := &Manager{
 | 
			
		||||
		cfg: cfg,
 | 
			
		||||
		bus: bus,
 | 
			
		||||
 | 
			
		||||
		db:           db,
 | 
			
		||||
		wgController: wgController,
 | 
			
		||||
		mux:          &sync.Mutex{},
 | 
			
		||||
		db: db,
 | 
			
		||||
		wg: wg,
 | 
			
		||||
		nl: nl,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	m.connectToMessageBus()
 | 
			
		||||
| 
						 | 
				
			
			@ -89,82 +85,419 @@ func (m Manager) StartBackgroundJobs(_ context.Context) {
 | 
			
		|||
	// this is a no-op for now
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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()
 | 
			
		||||
func (m Manager) handleRouteUpdateEvent(srcDescription string) {
 | 
			
		||||
	slog.Debug("handling route update event", "source", srcDescription)
 | 
			
		||||
 | 
			
		||||
	slog.Debug("handling route update event", "info", info.String())
 | 
			
		||||
 | 
			
		||||
	if !info.ManagementEnabled() {
 | 
			
		||||
		return // route management disabled
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err := m.syncRoutes(context.Background(), info)
 | 
			
		||||
	err := m.syncRoutes(context.Background())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		slog.Error("failed to synchronize routes",
 | 
			
		||||
			"info", info.String(), "error", err)
 | 
			
		||||
		return
 | 
			
		||||
			"source", srcDescription,
 | 
			
		||||
			"error", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	slog.Debug("routes synchronized", "info", info.String())
 | 
			
		||||
	slog.Debug("routes synchronized", "source", srcDescription)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err := m.removeRoutes(context.Background(), info)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		slog.Error("failed to synchronize routes",
 | 
			
		||||
			"info", info.String(), "error", err)
 | 
			
		||||
		return
 | 
			
		||||
	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", "info", info.String())
 | 
			
		||||
	slog.Debug("routes removed", "table", info.String())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Manager) syncRoutes(ctx context.Context, info domain.RoutingTableInfo) error {
 | 
			
		||||
	rc, ok := m.wgController.GetController(info.Interface).(RoutesController)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		slog.Warn("no capable routes-controller found for interface", "interface", info.Interface.Identifier)
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !info.Interface.ManageRoutingTable() {
 | 
			
		||||
		slog.Debug("interface does not manage routing table, skipping route update",
 | 
			
		||||
			"interface", info.Interface.Identifier)
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err := rc.SetRoutes(ctx, info)
 | 
			
		||||
func (m Manager) syncRoutes(ctx context.Context) error {
 | 
			
		||||
	interfaces, err := m.db.GetAllInterfaces(ctx)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to set routes for interface %s: %w", info.Interface.Identifier, err)
 | 
			
		||||
		return fmt.Errorf("failed to find all interfaces: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	rules := map[int][]routeRuleInfo{
 | 
			
		||||
		netlink.FAMILY_V4: nil,
 | 
			
		||||
		netlink.FAMILY_V6: nil,
 | 
			
		||||
	}
 | 
			
		||||
	for _, iface := range interfaces {
 | 
			
		||||
		if iface.IsDisabled() {
 | 
			
		||||
			continue // disabled interface does not need route entries
 | 
			
		||||
		}
 | 
			
		||||
		if !iface.ManageRoutingTable() {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to find peers for %s: %w", iface.Identifier, err)
 | 
			
		||||
		}
 | 
			
		||||
		allowedIPs := iface.GetAllowedIPs(peers)
 | 
			
		||||
		defRouteV4, defRouteV6 := m.containsDefaultRoute(allowedIPs)
 | 
			
		||||
 | 
			
		||||
		link, err := m.nl.LinkByName(string(iface.Identifier))
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to find physical link for %s: %w", iface.Identifier, err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		table, fwmark, err := m.getRoutingTableAndFwMark(&iface, link)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to get table and fwmark for %s: %w", iface.Identifier, err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := m.setInterfaceRoutes(link, table, allowedIPs); err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to set routes for %s: %w", iface.Identifier, err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := m.removeDeprecatedRoutes(link, netlink.FAMILY_V4, allowedIPs); err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to remove deprecated v4 routes for %s: %w", iface.Identifier, err)
 | 
			
		||||
		}
 | 
			
		||||
		if err := m.removeDeprecatedRoutes(link, netlink.FAMILY_V6, allowedIPs); err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to remove deprecated v6 routes for %s: %w", iface.Identifier, err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if table != 0 {
 | 
			
		||||
			rules[netlink.FAMILY_V4] = append(rules[netlink.FAMILY_V4], routeRuleInfo{
 | 
			
		||||
				ifaceId:    iface.Identifier,
 | 
			
		||||
				fwMark:     fwmark,
 | 
			
		||||
				table:      table,
 | 
			
		||||
				family:     netlink.FAMILY_V4,
 | 
			
		||||
				hasDefault: defRouteV4,
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
		if table != 0 {
 | 
			
		||||
			rules[netlink.FAMILY_V6] = append(rules[netlink.FAMILY_V6], routeRuleInfo{
 | 
			
		||||
				ifaceId:    iface.Identifier,
 | 
			
		||||
				fwMark:     fwmark,
 | 
			
		||||
				table:      table,
 | 
			
		||||
				family:     netlink.FAMILY_V6,
 | 
			
		||||
				hasDefault: defRouteV6,
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return m.syncRouteRules(rules)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Manager) syncRouteRules(allRules map[int][]routeRuleInfo) error {
 | 
			
		||||
	for family, rules := range allRules {
 | 
			
		||||
		// update fwmark rules
 | 
			
		||||
		if err := m.setFwMarkRules(rules, family); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// update main rule
 | 
			
		||||
		if err := m.setMainRule(rules, family); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// cleanup old main rules
 | 
			
		||||
		if err := m.cleanupMainRule(rules, family); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Manager) setFwMarkRules(rules []routeRuleInfo, family int) error {
 | 
			
		||||
	for _, rule := range rules {
 | 
			
		||||
		existingRules, err := m.nl.RuleList(family)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to get existing rules for family %d: %w", family, err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		ruleExists := false
 | 
			
		||||
		for _, existingRule := range existingRules {
 | 
			
		||||
			if rule.fwMark == existingRule.Mark && rule.table == existingRule.Table {
 | 
			
		||||
				ruleExists = true
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if ruleExists {
 | 
			
		||||
			continue // rule already exists, no need to recreate it
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// create missing rule
 | 
			
		||||
		if err := m.nl.RuleAdd(&netlink.Rule{
 | 
			
		||||
			Family:            family,
 | 
			
		||||
			Table:             rule.table,
 | 
			
		||||
			Mark:              rule.fwMark,
 | 
			
		||||
			Invert:            true,
 | 
			
		||||
			SuppressIfgroup:   -1,
 | 
			
		||||
			SuppressPrefixlen: -1,
 | 
			
		||||
			Priority:          m.getRulePriority(existingRules),
 | 
			
		||||
			Mask:              nil,
 | 
			
		||||
			Goto:              -1,
 | 
			
		||||
			Flow:              -1,
 | 
			
		||||
		}); err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to setup rule for fwmark %d and table %d: %w", rule.fwMark, rule.table, err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Manager) 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)
 | 
			
		||||
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 remove routes for interface %s: %w", info.Interface.Identifier, err)
 | 
			
		||||
		return fmt.Errorf("failed to get existing rules for family %d: %w", family, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, existingRule := range existingRules {
 | 
			
		||||
		if fwmark == existingRule.Mark && table == existingRule.Table {
 | 
			
		||||
			existingRule.Family = family // set family, somehow the RuleList method does not populate the family field
 | 
			
		||||
			if err := m.nl.RuleDel(&existingRule); err != nil {
 | 
			
		||||
				return fmt.Errorf("failed to delete fwmark rule: %w", err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Manager) setMainRule(rules []routeRuleInfo, family int) error {
 | 
			
		||||
	shouldHaveMainRule := false
 | 
			
		||||
	for _, rule := range rules {
 | 
			
		||||
		if rule.hasDefault == true {
 | 
			
		||||
			shouldHaveMainRule = true
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if !shouldHaveMainRule {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	existingRules, err := m.nl.RuleList(family)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to get existing rules for family %d: %w", family, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ruleExists := false
 | 
			
		||||
	for _, existingRule := range existingRules {
 | 
			
		||||
		if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
 | 
			
		||||
			ruleExists = true
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if ruleExists {
 | 
			
		||||
		return nil // rule already exists, skip re-creation
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := m.nl.RuleAdd(&netlink.Rule{
 | 
			
		||||
		Family:            family,
 | 
			
		||||
		Table:             unix.RT_TABLE_MAIN,
 | 
			
		||||
		SuppressIfgroup:   -1,
 | 
			
		||||
		SuppressPrefixlen: 0,
 | 
			
		||||
		Priority:          m.getMainRulePriority(existingRules),
 | 
			
		||||
		Mark:              0,
 | 
			
		||||
		Mask:              nil,
 | 
			
		||||
		Goto:              -1,
 | 
			
		||||
		Flow:              -1,
 | 
			
		||||
	}); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to setup rule for main table: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Manager) cleanupMainRule(rules []routeRuleInfo, family int) error {
 | 
			
		||||
	existingRules, err := m.nl.RuleList(family)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to get existing rules for family %d: %w", family, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	shouldHaveMainRule := false
 | 
			
		||||
	for _, rule := range rules {
 | 
			
		||||
		if rule.hasDefault == true {
 | 
			
		||||
			shouldHaveMainRule = true
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	mainRules := 0
 | 
			
		||||
	for _, existingRule := range existingRules {
 | 
			
		||||
		if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
 | 
			
		||||
			mainRules++
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	removalCount := 0
 | 
			
		||||
	if mainRules > 1 {
 | 
			
		||||
		removalCount = mainRules - 1 // we only want one single rule
 | 
			
		||||
	}
 | 
			
		||||
	if !shouldHaveMainRule {
 | 
			
		||||
		removalCount = mainRules
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, existingRule := range existingRules {
 | 
			
		||||
		if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
 | 
			
		||||
			if removalCount > 0 {
 | 
			
		||||
				existingRule.Family = family // set family, somehow the RuleList method does not populate the family field
 | 
			
		||||
				if err := m.nl.RuleDel(&existingRule); err != nil {
 | 
			
		||||
					return fmt.Errorf("failed to delete main rule: %w", err)
 | 
			
		||||
				}
 | 
			
		||||
				removalCount--
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Manager) getMainRulePriority(existingRules []netlink.Rule) int {
 | 
			
		||||
	prio := m.cfg.Advanced.RulePrioOffset
 | 
			
		||||
	for {
 | 
			
		||||
		isFresh := true
 | 
			
		||||
		for _, existingRule := range existingRules {
 | 
			
		||||
			if existingRule.Priority == prio {
 | 
			
		||||
				isFresh = false
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		if isFresh {
 | 
			
		||||
			break
 | 
			
		||||
		} else {
 | 
			
		||||
			prio++
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return prio
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Manager) getRulePriority(existingRules []netlink.Rule) int {
 | 
			
		||||
	prio := 32700 // linux main rule has a prio of 32766
 | 
			
		||||
	for {
 | 
			
		||||
		isFresh := true
 | 
			
		||||
		for _, existingRule := range existingRules {
 | 
			
		||||
			if existingRule.Priority == prio {
 | 
			
		||||
				isFresh = false
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		if isFresh {
 | 
			
		||||
			break
 | 
			
		||||
		} else {
 | 
			
		||||
			prio--
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return prio
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Manager) setInterfaceRoutes(link netlink.Link, table int, allowedIPs []domain.Cidr) error {
 | 
			
		||||
	for _, allowedIP := range allowedIPs {
 | 
			
		||||
		err := m.nl.RouteReplace(&netlink.Route{
 | 
			
		||||
			LinkIndex: link.Attrs().Index,
 | 
			
		||||
			Dst:       allowedIP.IpNet(),
 | 
			
		||||
			Table:     table,
 | 
			
		||||
			Scope:     unix.RT_SCOPE_LINK,
 | 
			
		||||
			Type:      unix.RTN_UNICAST,
 | 
			
		||||
		})
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to add/update route %s: %w", allowedIP.String(), err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Manager) removeDeprecatedRoutes(link netlink.Link, family int, allowedIPs []domain.Cidr) error {
 | 
			
		||||
	rawRoutes, err := m.nl.RouteListFiltered(family, &netlink.Route{
 | 
			
		||||
		LinkIndex: link.Attrs().Index,
 | 
			
		||||
		Table:     unix.RT_TABLE_UNSPEC, // all tables
 | 
			
		||||
		Scope:     unix.RT_SCOPE_LINK,
 | 
			
		||||
		Type:      unix.RTN_UNICAST,
 | 
			
		||||
	}, netlink.RT_FILTER_TABLE|netlink.RT_FILTER_TYPE|netlink.RT_FILTER_OIF)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to fetch raw routes: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	for _, rawRoute := range rawRoutes {
 | 
			
		||||
		if rawRoute.Dst == nil { // handle default route
 | 
			
		||||
			var netlinkAddr domain.Cidr
 | 
			
		||||
			if family == netlink.FAMILY_V4 {
 | 
			
		||||
				netlinkAddr, _ = domain.CidrFromString("0.0.0.0/0")
 | 
			
		||||
			} else {
 | 
			
		||||
				netlinkAddr, _ = domain.CidrFromString("::/0")
 | 
			
		||||
			}
 | 
			
		||||
			rawRoute.Dst = netlinkAddr.IpNet()
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		netlinkAddr := domain.CidrFromIpNet(*rawRoute.Dst)
 | 
			
		||||
		remove := true
 | 
			
		||||
		for _, allowedIP := range allowedIPs {
 | 
			
		||||
			if netlinkAddr == allowedIP {
 | 
			
		||||
				remove = false
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !remove {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		err := m.nl.RouteDel(&rawRoute)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to remove deprecated route %s: %w", netlinkAddr.String(), err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Manager) getRoutingTableAndFwMark(iface *domain.Interface, link netlink.Link) (
 | 
			
		||||
	table int,
 | 
			
		||||
	fwmark uint32,
 | 
			
		||||
	err error,
 | 
			
		||||
) {
 | 
			
		||||
	table = iface.GetRoutingTable()
 | 
			
		||||
	fwmark = iface.FirewallMark
 | 
			
		||||
 | 
			
		||||
	if fwmark == 0 {
 | 
			
		||||
		// generate a new (temporary) firewall mark based on the interface index
 | 
			
		||||
		fwmark = uint32(m.cfg.Advanced.RouteTableOffset + link.Attrs().Index)
 | 
			
		||||
		slog.Debug("using fwmark to handle routes",
 | 
			
		||||
			"interface", iface.Identifier,
 | 
			
		||||
			"fwmark", fwmark)
 | 
			
		||||
 | 
			
		||||
		// apply the temporary fwmark to the wireguard interface
 | 
			
		||||
		err = m.setFwMark(iface.Identifier, int(fwmark))
 | 
			
		||||
	}
 | 
			
		||||
	if table == 0 {
 | 
			
		||||
		table = int(fwmark) // generate a new routing table base on interface index
 | 
			
		||||
		slog.Debug("using routing table to handle default routes",
 | 
			
		||||
			"interface", iface.Identifier,
 | 
			
		||||
			"table", table)
 | 
			
		||||
	}
 | 
			
		||||
	return
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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,6 +1,7 @@
 | 
			
		|||
package wireguard
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"maps"
 | 
			
		||||
| 
						 | 
				
			
			@ -11,9 +12,33 @@ 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 domain.InterfaceController
 | 
			
		||||
	Implementation InterfaceController
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ControllerManager struct {
 | 
			
		||||
| 
						 | 
				
			
			@ -93,11 +118,11 @@ func (c *ControllerManager) logRegisteredControllers() {
 | 
			
		|||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *ControllerManager) GetControllerByName(backend domain.InterfaceBackend) domain.InterfaceController {
 | 
			
		||||
func (c *ControllerManager) GetControllerByName(backend domain.InterfaceBackend) InterfaceController {
 | 
			
		||||
	return c.getController(backend, "").Implementation
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *ControllerManager) GetController(iface domain.Interface) domain.InterfaceController {
 | 
			
		||||
func (c *ControllerManager) GetController(iface domain.Interface) InterfaceController {
 | 
			
		||||
	return c.getController(iface.Backend, iface.Identifier).Implementation
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,9 +38,9 @@ type InterfaceAndPeerDatabaseRepo interface {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
type WgQuickController interface {
 | 
			
		||||
	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
 | 
			
		||||
	ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error
 | 
			
		||||
	SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error
 | 
			
		||||
	UnsetDNS(id domain.InterfaceIdentifier) error
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type EventBus interface {
 | 
			
		||||
| 
						 | 
				
			
			@ -53,10 +53,11 @@ type EventBus interface {
 | 
			
		|||
// endregion dependencies
 | 
			
		||||
 | 
			
		||||
type Manager struct {
 | 
			
		||||
	cfg *config.Config
 | 
			
		||||
	bus EventBus
 | 
			
		||||
	db  InterfaceAndPeerDatabaseRepo
 | 
			
		||||
	wg  *ControllerManager
 | 
			
		||||
	cfg   *config.Config
 | 
			
		||||
	bus   EventBus
 | 
			
		||||
	db    InterfaceAndPeerDatabaseRepo
 | 
			
		||||
	wg    *ControllerManager
 | 
			
		||||
	quick WgQuickController
 | 
			
		||||
 | 
			
		||||
	userLockMap *sync.Map
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -65,6 +66,7 @@ func NewWireGuardManager(
 | 
			
		|||
	cfg *config.Config,
 | 
			
		||||
	bus EventBus,
 | 
			
		||||
	wg *ControllerManager,
 | 
			
		||||
	quick WgQuickController,
 | 
			
		||||
	db InterfaceAndPeerDatabaseRepo,
 | 
			
		||||
) (*Manager, error) {
 | 
			
		||||
	m := &Manager{
 | 
			
		||||
| 
						 | 
				
			
			@ -72,6 +74,7 @@ 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, existingPeers, err := m.db.GetInterfaceAndPeers(ctx, id)
 | 
			
		||||
	existingInterface, err := m.db.GetInterface(ctx, id)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("unable to find interface %s: %w", id, err)
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -462,29 +462,21 @@ 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
 | 
			
		||||
 | 
			
		||||
	if err := m.handleInterfacePreSaveHooks(ctx, existingInterface, !existingInterface.IsDisabled(),
 | 
			
		||||
		false); err != nil {
 | 
			
		||||
	physicalInterface, _ := m.wg.GetController(*existingInterface).GetInterface(ctx, id)
 | 
			
		||||
 | 
			
		||||
	if err := m.handleInterfacePreSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil {
 | 
			
		||||
		return fmt.Errorf("pre-delete hooks failed: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := m.handleInterfacePreSaveActions(ctx, existingInterface); err != nil {
 | 
			
		||||
	if err := m.handleInterfacePreSaveActions(existingInterface); err != nil {
 | 
			
		||||
		return fmt.Errorf("pre-delete actions failed: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := m.deleteInterfacePeers(ctx, existingInterface, existingPeers); err != nil {
 | 
			
		||||
	if err := m.deleteInterfacePeers(ctx, id); err != nil {
 | 
			
		||||
		return fmt.Errorf("peer deletion failure: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -496,12 +488,16 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
 | 
			
		|||
		return fmt.Errorf("deletion failure: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := m.handleInterfacePostSaveHooks(
 | 
			
		||||
		ctx,
 | 
			
		||||
		existingInterface,
 | 
			
		||||
		!existingInterface.IsDisabled(),
 | 
			
		||||
		false,
 | 
			
		||||
	); err != nil {
 | 
			
		||||
	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 {
 | 
			
		||||
		return fmt.Errorf("post-delete hooks failed: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -520,21 +516,17 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
 | 
			
		|||
		return nil, fmt.Errorf("interface validation failed: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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)
 | 
			
		||||
	}
 | 
			
		||||
	oldEnabled, newEnabled := m.getInterfaceStateHistory(ctx, iface)
 | 
			
		||||
 | 
			
		||||
	if err := m.handleInterfacePreSaveHooks(ctx, iface, oldEnabled, newEnabled); err != nil {
 | 
			
		||||
	if err := m.handleInterfacePreSaveHooks(iface, oldEnabled, newEnabled); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("pre-save hooks failed: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := m.handleInterfacePreSaveActions(ctx, iface); err != nil {
 | 
			
		||||
	if err := m.handleInterfacePreSaveActions(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,
 | 
			
		||||
| 
						 | 
				
			
			@ -577,35 +569,20 @@ 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{
 | 
			
		||||
			Interface:  *iface,
 | 
			
		||||
			AllowedIps: iface.GetAllowedIPs(peers),
 | 
			
		||||
			FwMark:     iface.FirewallMark,
 | 
			
		||||
			Table:      iface.GetRoutingTable(),
 | 
			
		||||
			TableStr:   iface.RoutingTable,
 | 
			
		||||
			FwMark: fwMark,
 | 
			
		||||
			Table:  iface.GetRoutingTable(),
 | 
			
		||||
		})
 | 
			
		||||
	} else {
 | 
			
		||||
		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
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
		m.bus.Publish(app.TopicRouteUpdate, "interface updated: "+string(iface.Identifier))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := m.handleInterfacePostSaveHooks(ctx, iface, oldEnabled, newEnabled); err != nil {
 | 
			
		||||
	if err := m.handleInterfacePostSaveHooks(iface, oldEnabled, newEnabled); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("post-save hooks failed: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -641,90 +618,60 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
 | 
			
		|||
	return iface, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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(ctx context.Context, iface *domain.Interface) error {
 | 
			
		||||
	wgQuickController, ok := m.wg.GetController(*iface).(WgQuickController)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		slog.Warn("failed to perform pre-save actions", "interface", iface.Identifier,
 | 
			
		||||
			"error", "no capable controller found")
 | 
			
		||||
		return nil
 | 
			
		||||
func (m Manager) 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
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 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 !oldInterface.IsDisabled(), !iface.IsDisabled()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Manager) handleInterfacePreSaveHooks(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	iface *domain.Interface,
 | 
			
		||||
	oldEnabled, newEnabled bool,
 | 
			
		||||
) error {
 | 
			
		||||
func (m Manager) handleInterfacePreSaveHooks(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 := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PreUp); err != nil {
 | 
			
		||||
		if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PreUp); err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to execute pre-up hook: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PreDown); err != nil {
 | 
			
		||||
		if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PreDown); err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to execute pre-down hook: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Manager) handleInterfacePostSaveHooks(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	iface *domain.Interface,
 | 
			
		||||
	oldEnabled, newEnabled bool,
 | 
			
		||||
) error {
 | 
			
		||||
func (m Manager) handleInterfacePostSaveHooks(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 := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PostUp); err != nil {
 | 
			
		||||
		if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PostUp); err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to execute post-up hook: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PostDown); err != nil {
 | 
			
		||||
		if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PostDown); err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to execute post-down hook: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -852,7 +799,7 @@ func (m Manager) getFreshListenPort(ctx context.Context) (port int, err error) {
 | 
			
		|||
 | 
			
		||||
func (m Manager) importInterface(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	backend domain.InterfaceController,
 | 
			
		||||
	backend InterfaceController,
 | 
			
		||||
	in *domain.PhysicalInterface,
 | 
			
		||||
	peers []domain.PhysicalPeer,
 | 
			
		||||
) error {
 | 
			
		||||
| 
						 | 
				
			
			@ -954,9 +901,13 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain
 | 
			
		|||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Manager) deleteInterfacePeers(ctx context.Context, iface *domain.Interface, allPeers []domain.Peer) error {
 | 
			
		||||
func (m Manager) deleteInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) error {
 | 
			
		||||
	iface, allPeers, err := m.db.GetInterfaceAndPeers(ctx, id)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	for _, peer := range allPeers {
 | 
			
		||||
		err := m.wg.GetController(*iface).DeletePeer(ctx, iface.Identifier, peer.Identifier)
 | 
			
		||||
		err = m.wg.GetController(*iface).DeletePeer(ctx, id, peer.Identifier)
 | 
			
		||||
		if err != nil && !errors.Is(err, os.ErrNotExist) {
 | 
			
		||||
			return fmt.Errorf("wireguard peer deletion failure for %s: %w", peer.Identifier, err)
 | 
			
		||||
		}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -388,20 +388,9 @@ 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, domain.RoutingTableInfo{
 | 
			
		||||
		Interface:  *iface,
 | 
			
		||||
		AllowedIps: iface.GetAllowedIPs(peers),
 | 
			
		||||
		FwMark:     iface.FirewallMark,
 | 
			
		||||
		Table:      iface.GetRoutingTable(),
 | 
			
		||||
		TableStr:   iface.RoutingTable,
 | 
			
		||||
	})
 | 
			
		||||
	m.bus.Publish(app.TopicRouteUpdate, "peers updated")
 | 
			
		||||
	// Update interface after peers have changed
 | 
			
		||||
	m.bus.Publish(app.TopicPeerInterfaceUpdated, peer.InterfaceIdentifier)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -449,26 +438,20 @@ 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]domain.Interface)
 | 
			
		||||
	interfaces := make(map[domain.InterfaceIdentifier]struct{})
 | 
			
		||||
 | 
			
		||||
	for _, peer := range peers {
 | 
			
		||||
		// get interface from db if it is not yet in the map
 | 
			
		||||
		if _, ok := interfaces[peer.InterfaceIdentifier]; !ok {
 | 
			
		||||
			iface, err := m.db.GetInterface(ctx, peer.InterfaceIdentifier)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return fmt.Errorf("unable to find interface %s: %w", peer.InterfaceIdentifier, err)
 | 
			
		||||
			}
 | 
			
		||||
			interfaces[peer.InterfaceIdentifier] = *iface
 | 
			
		||||
		iface, err := m.db.GetInterface(ctx, peer.InterfaceIdentifier)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("unable to find interface %s: %w", peer.InterfaceIdentifier, err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		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
 | 
			
		||||
| 
						 | 
				
			
			@ -492,22 +475,13 @@ func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error {
 | 
			
		|||
				Peer:   *peer,
 | 
			
		||||
			},
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
		interfaces[peer.InterfaceIdentifier] = struct{}{}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Update routes after peers have changed
 | 
			
		||||
	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,
 | 
			
		||||
		})
 | 
			
		||||
	if len(interfaces) != 0 {
 | 
			
		||||
		m.bus.Publish(app.TopicRouteUpdate, "peers updated")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for iface := range interfaces {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -211,10 +211,6 @@ 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.
 | 
			
		||||
| 
						 | 
				
			
			@ -256,10 +252,6 @@ 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,7 +13,6 @@ 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,9 +134,6 @@ 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,6 +41,4 @@ 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,7 +13,6 @@ import (
 | 
			
		|||
	"golang.org/x/sys/unix"
 | 
			
		||||
 | 
			
		||||
	"github.com/h44z/wg-portal/internal"
 | 
			
		||||
	"github.com/h44z/wg-portal/internal/config"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
| 
						 | 
				
			
			@ -133,30 +132,17 @@ 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
 | 
			
		||||
 | 
			
		||||
	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...)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
	for _, peer := range peers {
 | 
			
		||||
		for _, ip := range peer.Interface.Addresses {
 | 
			
		||||
			allowedCidrs = append(allowedCidrs, ip.HostAddr())
 | 
			
		||||
		}
 | 
			
		||||
	case InterfaceTypeClient:
 | 
			
		||||
		for _, peer := range peers {
 | 
			
		||||
			allowedIPs, err := CidrsFromString(peer.AllowedIPsStr.GetValue())
 | 
			
		||||
		if peer.ExtraAllowedIPsStr != "" {
 | 
			
		||||
			extraIPs, err := CidrsFromString(peer.ExtraAllowedIPsStr)
 | 
			
		||||
			if err == nil {
 | 
			
		||||
				allowedCidrs = append(allowedCidrs, allowedIPs...)
 | 
			
		||||
				allowedCidrs = append(allowedCidrs, extraIPs...)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -173,7 +159,6 @@ 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 == "":
 | 
			
		||||
| 
						 | 
				
			
			@ -181,9 +166,6 @@ 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 {
 | 
			
		||||
| 
						 | 
				
			
			@ -196,9 +178,6 @@ 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)
 | 
			
		||||
| 
						 | 
				
			
			@ -329,18 +308,12 @@ func MergeToPhysicalInterface(pi *PhysicalInterface, i *Interface) {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
type RoutingTableInfo struct {
 | 
			
		||||
	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
 | 
			
		||||
	FwMark uint32
 | 
			
		||||
	Table  int
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (r RoutingTableInfo) String() string {
 | 
			
		||||
	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))
 | 
			
		||||
	return fmt.Sprintf("%d -> %d", r.FwMark, r.Table)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (r RoutingTableInfo) ManagementEnabled() bool {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,27 +0,0 @@
 | 
			
		|||
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,8 +5,6 @@ import (
 | 
			
		|||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
 | 
			
		||||
	"github.com/h44z/wg-portal/internal/config"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestInterface_IsDisabledReturnsTrueWhenDisabled(t *testing.T) {
 | 
			
		||||
| 
						 | 
				
			
			@ -39,9 +37,8 @@ func TestInterface_GetConfigFileNameReturnsCorrectFileName(t *testing.T) {
 | 
			
		|||
	assert.Equal(t, expected, iface.GetConfigFileName())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestInterface_GetAllowedIPsReturnsCorrectCidrsServerMode(t *testing.T) {
 | 
			
		||||
func TestInterface_GetAllowedIPsReturnsCorrectCidrs(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},
 | 
			
		||||
| 
						 | 
				
			
			@ -49,45 +46,16 @@ func TestInterface_GetAllowedIPsReturnsCorrectCidrsServerMode(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{Type: InterfaceTypeServer}
 | 
			
		||||
	iface := &Interface{}
 | 
			
		||||
	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}))
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -98,22 +66,10 @@ 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: "", Backend: config.LocalBackendName}
 | 
			
		||||
	iface := &Interface{RoutingTable: ""}
 | 
			
		||||
	assert.Equal(t, 0, iface.GetRoutingTable())
 | 
			
		||||
 | 
			
		||||
	iface.RoutingTable = "off"
 | 
			
		||||
| 
						 | 
				
			
			@ -125,17 +81,3 @@ 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,10 +26,6 @@ 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 {
 | 
			
		||||
| 
						 | 
				
			
			@ -203,26 +199,3 @@ 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
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -308,33 +308,22 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
 | 
			
		|||
 | 
			
		||||
func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
 | 
			
		||||
	pp.Identifier = p.Identifier
 | 
			
		||||
	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() // 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
 | 
			
		||||
	pp.Endpoint = p.Endpoint.GetValue()
 | 
			
		||||
	if p.Interface.Type == InterfaceTypeServer {
 | 
			
		||||
		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
 | 
			
		||||
	} else {
 | 
			
		||||
		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
 | 
			
		||||
			allowedIPs[i] = ip.HostAddr()
 | 
			
		||||
		}
 | 
			
		||||
		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:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -267,7 +267,6 @@ 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,12 +6,8 @@ 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