mirror of https://github.com/h44z/wg-portal.git
				
				
				
			Compare commits
	
		
			18 Commits
		
	
	
		
			v2.1.0-rc.
			...
			master
		
	
	| Author | SHA1 | Date | 
|---|---|---|
|  | da76327569 | |
|  | c154cb3977 | |
|  | 7bca35728d | |
|  | 3d923b328e | |
|  | 139fb17f98 | |
|  | faf1d995a8 | |
|  | f53d0b3d7f | |
|  | cdf3a49801 | |
|  | 298c9405f6 | |
|  | c7724b620a | |
|  | 4d19f1d8bb | |
|  | 3f539a1615 | |
|  | 0305911467 | |
|  | 85f7a5a9a6 | |
|  | fb509a39b8 | |
|  | 9e6ad98c4e | |
|  | 05fbcccc9c | |
|  | 97b6c398e8 | 
|  | @ -0,0 +1,3 @@ | ||||||
|  | # These are supported funding model platforms | ||||||
|  | 
 | ||||||
|  | github: h44z # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| Copyright (c) 2020-2023 Christoph Haas | Copyright (c) 2020-2025 Christoph Haas | ||||||
| 
 | 
 | ||||||
| Permission is hereby granted, free of charge, to any person obtaining | Permission is hereby granted, free of charge, to any person obtaining | ||||||
| a copy of this software and associated documentation files (the | a copy of this software and associated documentation files (the | ||||||
|  |  | ||||||
							
								
								
									
										15
									
								
								README.md
								
								
								
								
							
							
						
						
									
										15
									
								
								README.md
								
								
								
								
							|  | @ -21,7 +21,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos | ||||||
| ## Features | ## Features | ||||||
| 
 | 
 | ||||||
| * Self-hosted - the whole application is a single binary | * Self-hosted - the whole application is a single binary | ||||||
| * Responsive multi-language web UI written in Vue.js | * Responsive multi-language web UI with dark-mode written in Vue.js | ||||||
| * Automatically selects IP from the network pool assigned to the client | * Automatically selects IP from the network pool assigned to the client | ||||||
| * QR-Code for convenient mobile client configuration | * QR-Code for convenient mobile client configuration | ||||||
| * Sends email to the client with QR-code and client config | * Sends email to the client with QR-code and client config | ||||||
|  | @ -32,7 +32,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos | ||||||
| * Docker ready | * Docker ready | ||||||
| * Can be used with existing WireGuard setups | * Can be used with existing WireGuard setups | ||||||
| * Support for multiple WireGuard interfaces | * Support for multiple WireGuard interfaces | ||||||
| * Supports multiple WireGuard backends (wgctrl or MikroTik [BETA]) | * Supports multiple WireGuard backends (wgctrl or MikroTik) | ||||||
| * Peer Expiry Feature | * Peer Expiry Feature | ||||||
| * Handles route and DNS settings like wg-quick does | * Handles route and DNS settings like wg-quick does | ||||||
| * Exposes Prometheus metrics for monitoring and alerting | * Exposes Prometheus metrics for monitoring and alerting | ||||||
|  | @ -62,6 +62,17 @@ For the complete documentation visit [wgportal.org](https://wgportal.org). | ||||||
| 
 | 
 | ||||||
| * MIT License. [MIT](LICENSE.txt) or <https://opensource.org/licenses/MIT> | * MIT License. [MIT](LICENSE.txt) or <https://opensource.org/licenses/MIT> | ||||||
| 
 | 
 | ||||||
|  | ## Contributors and Sponsors | ||||||
|  | 
 | ||||||
|  | Thanks so much for all your contributions! 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] | > [!IMPORTANT] | ||||||
| > Since the project was accepted by the Docker-Sponsored Open Source Program, the Docker image location has moved to [wgportal/wg-portal](https://hub.docker.com/r/wgportal/wg-portal). | > Since the project was accepted by the Docker-Sponsored Open Source Program, the Docker image location has moved to [wgportal/wg-portal](https://hub.docker.com/r/wgportal/wg-portal). | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ If you believe you've found a security issue in one of the supported versions of | ||||||
| | Version | Supported          | | | Version | Supported          | | ||||||
| |---------|--------------------| | |---------|--------------------| | ||||||
| | v2.x    | :white_check_mark: | | | v2.x    | :white_check_mark: | | ||||||
| | v1.x    | :white_check_mark: | | | v1.x    | :x:                | | ||||||
| 
 | 
 | ||||||
| ## Reporting a Vulnerability | ## Reporting a Vulnerability | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -53,8 +53,6 @@ func main() { | ||||||
| 	wireGuard, err := wireguard.NewControllerManager(cfg) | 	wireGuard, err := wireguard.NewControllerManager(cfg) | ||||||
| 	internal.AssertNoError(err) | 	internal.AssertNoError(err) | ||||||
| 
 | 
 | ||||||
| 	wgQuick := adapters.NewWgQuickRepo() |  | ||||||
| 
 |  | ||||||
| 	mailer := adapters.NewSmtpMailRepo(cfg.Mail) | 	mailer := adapters.NewSmtpMailRepo(cfg.Mail) | ||||||
| 
 | 
 | ||||||
| 	metricsServer := adapters.NewMetricsServer(cfg) | 	metricsServer := adapters.NewMetricsServer(cfg) | ||||||
|  | @ -93,7 +91,7 @@ func main() { | ||||||
| 	webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager) | 	webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager) | ||||||
| 	internal.AssertNoError(err) | 	internal.AssertNoError(err) | ||||||
| 
 | 
 | ||||||
| 	wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database) | 	wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, database) | ||||||
| 	internal.AssertNoError(err) | 	internal.AssertNoError(err) | ||||||
| 	wireGuardManager.StartBackgroundJobs(ctx) | 	wireGuardManager.StartBackgroundJobs(ctx) | ||||||
| 
 | 
 | ||||||
|  | @ -107,7 +105,7 @@ func main() { | ||||||
| 	mailManager, err := mail.NewMailManager(cfg, mailer, cfgFileManager, database, database) | 	mailManager, err := mail.NewMailManager(cfg, mailer, cfgFileManager, database, database) | ||||||
| 	internal.AssertNoError(err) | 	internal.AssertNoError(err) | ||||||
| 
 | 
 | ||||||
| 	routeManager, err := route.NewRouteManager(cfg, eventBus, database) | 	routeManager, err := route.NewRouteManager(cfg, eventBus, database, wireGuard) | ||||||
| 	internal.AssertNoError(err) | 	internal.AssertNoError(err) | ||||||
| 	routeManager.StartBackgroundJobs(ctx) | 	routeManager.StartBackgroundJobs(ctx) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 131 KiB | 
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 131 KiB | 
|  | @ -15,6 +15,9 @@ backend: | ||||||
|   # default backend decides where new interfaces are created |   # default backend decides where new interfaces are created | ||||||
|   default: mikrotik |   default: mikrotik | ||||||
| 
 | 
 | ||||||
|  |   # A prefix for resolvconf. Usually it is "tun.". If you are using systemd, the prefix should be empty. | ||||||
|  |   local_resolvconf_prefix: "tun." | ||||||
|  | 
 | ||||||
|   mikrotik: |   mikrotik: | ||||||
|     - id: mikrotik                   # unique id, not "local" |     - id: mikrotik                   # unique id, not "local" | ||||||
|       display_name: RouterOS RB5009  # optional nice name |       display_name: RouterOS RB5009  # optional nice name | ||||||
|  |  | ||||||
|  | @ -28,6 +28,7 @@ core: | ||||||
|    |    | ||||||
| backend: | backend: | ||||||
|   default: local |   default: local | ||||||
|  |   local_resolvconf_prefix: tun. | ||||||
| 
 | 
 | ||||||
| advanced: | advanced: | ||||||
|   log_level: info |   log_level: info | ||||||
|  | @ -72,6 +73,7 @@ mail: | ||||||
|   auth_type: plain |   auth_type: plain | ||||||
|   from: Wireguard Portal <noreply@wireguard.local> |   from: Wireguard Portal <noreply@wireguard.local> | ||||||
|   link_only: false |   link_only: false | ||||||
|  |   allow_peer_email: false | ||||||
| 
 | 
 | ||||||
| auth: | auth: | ||||||
|   oidc: [] |   oidc: [] | ||||||
|  | @ -184,6 +186,11 @@ The current MikroTik backend is in **BETA** and may not support all features. | ||||||
| - **Description:** The default backend to use for managing WireGuard interfaces.  | - **Description:** The default backend to use for managing WireGuard interfaces.  | ||||||
|   Valid options are: `local`, or other backend id's configured in the `mikrotik` section. |   Valid options are: `local`, or other backend id's configured in the `mikrotik` section. | ||||||
| 
 | 
 | ||||||
|  | ### `local_resolvconf_prefix` | ||||||
|  | - **Default:** `tun.` | ||||||
|  | - **Description:** Interface name prefix for WireGuard interfaces on the local system which is used to configure DNS servers with *resolvconf*.  | ||||||
|  |   It depends on the *resolvconf* implementation you are using, most use a prefix of `tun.`, but some have an empty prefix (e.g., systemd). | ||||||
|  | 
 | ||||||
| ### `ignored_local_interfaces` | ### `ignored_local_interfaces` | ||||||
| - **Default:** *(empty)* | - **Default:** *(empty)* | ||||||
| - **Description:** A list of interface names to exclude when enumerating local interfaces. | - **Description:** A list of interface names to exclude when enumerating local interfaces. | ||||||
|  | @ -381,6 +388,8 @@ Controls how WireGuard Portal collects and reports usage statistics, including p | ||||||
| ## Mail | ## Mail | ||||||
| 
 | 
 | ||||||
| Options for configuring email notifications or sending peer configurations via email.  | Options for configuring email notifications or sending peer configurations via email.  | ||||||
|  | By default, emails will only be sent to peers that have a valid user record linked.  | ||||||
|  | To send emails to all peers that have a valid email-address as user-identifier, set `allow_peer_email` to `true`. | ||||||
| 
 | 
 | ||||||
| ### `host` | ### `host` | ||||||
| - **Default:** `127.0.0.1` | - **Default:** `127.0.0.1` | ||||||
|  | @ -418,6 +427,12 @@ Options for configuring email notifications or sending peer configurations via e | ||||||
| - **Default:** `false` | - **Default:** `false` | ||||||
| - **Description:** If `true`, emails only contain a link to WireGuard Portal, rather than attaching the full configuration. | - **Description:** If `true`, emails only contain a link to WireGuard Portal, rather than attaching the full configuration. | ||||||
| 
 | 
 | ||||||
|  | ### `allow_peer_email` | ||||||
|  | - **Default:** `false` | ||||||
|  |   - **Description:** If `true`, and a peer has no valid user record linked, but the user-identifier of the peer is a valid email address, emails will be sent to that email address. | ||||||
|  |     If false, and the peer has no valid user record linked, emails will not be sent. | ||||||
|  |     If a peer has linked a valid user, the email address is always taken from the user record. | ||||||
|  | 
 | ||||||
| --- | --- | ||||||
| 
 | 
 | ||||||
| ## Auth | ## Auth | ||||||
|  | @ -497,13 +512,18 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`: | ||||||
|     - `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex. |     - `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex. | ||||||
| 
 | 
 | ||||||
| #### `registration_enabled` | #### `registration_enabled` | ||||||
| - **Default:** *(empty)* | - **Default:** `false` | ||||||
| - **Description:** If `true`, a new user will be created in WireGuard Portal if not already present. | - **Description:** If `true`, a new user will be created in WireGuard Portal if not already present. | ||||||
| 
 | 
 | ||||||
| #### `log_user_info` | #### `log_user_info` | ||||||
| - **Default:** *(empty)* | - **Default:** `false` | ||||||
| - **Description:** If `true`, OIDC user data is logged at the trace level upon login (for debugging). | - **Description:** If `true`, OIDC user data is logged at the trace level upon login (for debugging). | ||||||
| 
 | 
 | ||||||
|  | #### `log_sensitive_info` | ||||||
|  | - **Default:** `false` | ||||||
|  | - **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging). | ||||||
|  | - **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues. | ||||||
|  | 
 | ||||||
| --- | --- | ||||||
| 
 | 
 | ||||||
| ### OAuth | ### OAuth | ||||||
|  | @ -570,13 +590,18 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`: | ||||||
|   - `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex. |   - `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex. | ||||||
| 
 | 
 | ||||||
| #### `registration_enabled` | #### `registration_enabled` | ||||||
| - **Default:** *(empty)* | - **Default:** `false` | ||||||
| - **Description:** If `true`, new users are created automatically on successful login. | - **Description:** If `true`, new users are created automatically on successful login. | ||||||
| 
 | 
 | ||||||
| #### `log_user_info` | #### `log_user_info` | ||||||
| - **Default:** *(empty)* | - **Default:** `false` | ||||||
| - **Description:** If `true`, logs user info at the trace level upon login. | - **Description:** If `true`, logs user info at the trace level upon login. | ||||||
| 
 | 
 | ||||||
|  | #### `log_sensitive_info` | ||||||
|  | - **Default:** `false` | ||||||
|  | - **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging). | ||||||
|  | - **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues. | ||||||
|  | 
 | ||||||
| --- | --- | ||||||
| 
 | 
 | ||||||
| ### LDAP | ### LDAP | ||||||
|  | @ -593,11 +618,11 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`: | ||||||
| - **Description:** The LDAP server URL (e.g., `ldap://srv-ad01.company.local:389`). | - **Description:** The LDAP server URL (e.g., `ldap://srv-ad01.company.local:389`). | ||||||
| 
 | 
 | ||||||
| #### `start_tls` | #### `start_tls` | ||||||
| - **Default:** *(empty)* | - **Default:** `false` | ||||||
| - **Description:** If `true`, use STARTTLS to secure the LDAP connection. | - **Description:** If `true`, use STARTTLS to secure the LDAP connection. | ||||||
| 
 | 
 | ||||||
| #### `cert_validation` | #### `cert_validation` | ||||||
| - **Default:** *(empty)* | - **Default:** `false` | ||||||
| - **Description:** If `true`, validate the LDAP server’s TLS certificate. | - **Description:** If `true`, validate the LDAP server’s TLS certificate. | ||||||
| 
 | 
 | ||||||
| #### `tls_certificate_path` | #### `tls_certificate_path` | ||||||
|  | @ -667,19 +692,19 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`: | ||||||
|   ``` |   ``` | ||||||
| 
 | 
 | ||||||
| #### `disable_missing` | #### `disable_missing` | ||||||
| - **Default:** *(empty)* | - **Default:** `false` | ||||||
| - **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal. | - **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal. | ||||||
| 
 | 
 | ||||||
| #### `auto_re_enable` | #### `auto_re_enable` | ||||||
| - **Default:** *(empty)* | - **Default:** `false` | ||||||
| - **Description:** If `true`, users that where disabled because they were missing (see `disable_missing`) will be re-enabled once they are found again. | - **Description:** If `true`, users that where disabled because they were missing (see `disable_missing`) will be re-enabled once they are found again. | ||||||
| 
 | 
 | ||||||
| #### `registration_enabled` | #### `registration_enabled` | ||||||
| - **Default:** *(empty)* | - **Default:** `false` | ||||||
| - **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login. | - **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login. | ||||||
| 
 | 
 | ||||||
| #### `log_user_info` | #### `log_user_info` | ||||||
| - **Default:** *(empty)* | - **Default:** `false` | ||||||
| - **Description:** If `true`, logs LDAP user data at the trace level upon login. | - **Description:** If `true`, logs LDAP user data at the trace level upon login. | ||||||
| 
 | 
 | ||||||
| --- | --- | ||||||
|  |  | ||||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -0,0 +1,15 @@ | ||||||
|  | img-comparison-slider { | ||||||
|  |   visibility: hidden; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | img-comparison-slider [slot='second'] { | ||||||
|  |   display: none; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | img-comparison-slider.rendered { | ||||||
|  |   visibility: inherit; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | img-comparison-slider.rendered [slot='second'] { | ||||||
|  |   display: unset; | ||||||
|  | } | ||||||
|  | @ -68,7 +68,7 @@ | ||||||
| } | } | ||||||
| .tx-hero__image { | .tx-hero__image { | ||||||
|   max-width: 1000px; |   max-width: 1000px; | ||||||
|   min-width: 600px; |   min-width: 0; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   height: auto; |   height: auto; | ||||||
|   margin: 0 auto; |   margin: 0 auto; | ||||||
|  | @ -218,7 +218,7 @@ | ||||||
| 
 | 
 | ||||||
| .secondary-section .g .section .component-wrapper .responsive-grid .card { | .secondary-section .g .section .component-wrapper .responsive-grid .card { | ||||||
|   position: relative; |   position: relative; | ||||||
|   background-color:  #fff none repeat scroll 0% 0%; |   background-color:  #fff; | ||||||
|   padding: 1.5rem; |   padding: 1.5rem; | ||||||
|   display: flex; |   display: flex; | ||||||
|   flex-direction: row; |   flex-direction: row; | ||||||
|  | @ -300,6 +300,59 @@ | ||||||
|   background: var(--md-accent-fg-color--transparent); |   background: var(--md-accent-fg-color--transparent); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .before, | ||||||
|  | .after { | ||||||
|  |     margin: 0; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .after figcaption { | ||||||
|  |     background: #fff; | ||||||
|  |     font-weight: bold; | ||||||
|  |     border: 1px solid #c0c0c0; | ||||||
|  |     color: #000000; | ||||||
|  |     opacity: 0.9; | ||||||
|  |     padding: 9px; | ||||||
|  |     position: absolute; | ||||||
|  |     top: 100%; | ||||||
|  |     transform: translateY(-100%); | ||||||
|  |     line-height: 100%; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .before figcaption { | ||||||
|  |     background: #000; | ||||||
|  |     font-weight: bold; | ||||||
|  |     border: 1px solid #c0c0c0; | ||||||
|  |     color: #ffffff; | ||||||
|  |     opacity: 0.9; | ||||||
|  |     padding: 9px; | ||||||
|  |     position: absolute; | ||||||
|  |     top: 100%; | ||||||
|  |     transform: translateY(-100%); | ||||||
|  |     line-height: 100%; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .before figcaption { | ||||||
|  |     left: 0px; | ||||||
|  | } | ||||||
|  | .after figcaption { | ||||||
|  |     right: 0px; | ||||||
|  | } | ||||||
|  | .custom-animated-handle { | ||||||
|  |     transition: transform 0.2s; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .slider-with-animated-handle:hover .custom-animated-handle { | ||||||
|  |     transform: scale(1.2); | ||||||
|  | } | ||||||
|  | .md-typeset img-comparison-slider figure { | ||||||
|  |     margin: initial; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .first-overlay { | ||||||
|  |     color: #000; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| </style> | </style> | ||||||
| 
 | 
 | ||||||
|   <!-- Hero for landing page --> |   <!-- Hero for landing page --> | ||||||
|  | @ -310,7 +363,6 @@ | ||||||
|           <h1>A beautiful and simple UI to manage your WireGuard peers and interfaces</h1> |           <h1>A beautiful and simple UI to manage your WireGuard peers and interfaces</h1> | ||||||
|           <p>WireGuard Portal is an open source web-based user interface that makes it easy to setup and manage |           <p>WireGuard Portal is an open source web-based user interface that makes it easy to setup and manage | ||||||
|             WireGuard VPN connections. It's built on top of WireGuard's official <span class="em">wgctrl</span> library.</p> |             WireGuard VPN connections. It's built on top of WireGuard's official <span class="em">wgctrl</span> library.</p> | ||||||
|           </p> |  | ||||||
|           <a |           <a | ||||||
|             href="documentation/overview/" |             href="documentation/overview/" | ||||||
|             title="Get Started" |             title="Get Started" | ||||||
|  | @ -326,11 +378,34 @@ | ||||||
| 
 | 
 | ||||||
|   <div class="md-container"> |   <div class="md-container"> | ||||||
|     <div class="tx-hero__image"> |     <div class="tx-hero__image"> | ||||||
|       <img |       <div> | ||||||
|         src="{{config.site_url}}/assets/images/screenshot.png" |         <img-comparison-slider hover="hover"> | ||||||
|         alt="" |           <figure slot="first" class="before"> | ||||||
|         draggable="false" |             <img src="{{config.site_url}}/assets/images/wgportal_light.png" alt="Light Mode"/> | ||||||
|       > |             <figcaption>Light Mode</figcaption> | ||||||
|  |           </figure> | ||||||
|  |           <figure slot="second" class="after"> | ||||||
|  |             <img src="{{config.site_url}}/assets/images/wgportal_dark.png" alt="Dark Mode"/> | ||||||
|  |             <figcaption>Dark Mode</figcaption> | ||||||
|  |           </figure> | ||||||
|  |           <svg slot="handle" class="custom-animated-handle" xmlns="http://www.w3.org/2000/svg" width="100" viewBox="-8 -3 16 6"> | ||||||
|  |                 <!-- Left arrow (dark) --> | ||||||
|  |                 <path d="M -5 -2 L -7 0 L -5 2 M -5 -2 L -5 2" | ||||||
|  |                       stroke="#1a1a1a" | ||||||
|  |                       fill="#1a1a1a" | ||||||
|  |                       stroke-width="1" | ||||||
|  |                       vector-effect="non-scaling-stroke"> | ||||||
|  |                 </path> | ||||||
|  |                 <!-- Right arrow (white) --> | ||||||
|  |                 <path d="M 5 -2 L 7 0 L 5 2 M 5 -2 L 5 2" | ||||||
|  |                       stroke="#fff" | ||||||
|  |                       fill="#fff" | ||||||
|  |                       stroke-width="1" | ||||||
|  |                       vector-effect="non-scaling-stroke"> | ||||||
|  |                 </path> | ||||||
|  |           </svg> | ||||||
|  |         </img-comparison-slider> | ||||||
|  |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -30,6 +30,6 @@ | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@vitejs/plugin-vue": "^5.2.3", |     "@vitejs/plugin-vue": "^5.2.3", | ||||||
|     "sass-embedded": "^1.86.3", |     "sass-embedded": "^1.86.3", | ||||||
|     "vite": "6.3.4" |     "vite": "^6.3.6" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| <script setup> | <script setup> | ||||||
| import { RouterLink, RouterView } from 'vue-router'; | import { RouterLink, RouterView } from 'vue-router'; | ||||||
| import { computed, getCurrentInstance, onMounted, ref } from "vue"; | import {computed, getCurrentInstance, nextTick, onMounted, ref} from "vue"; | ||||||
| import { authStore } from "./stores/auth"; | import { authStore } from "./stores/auth"; | ||||||
| import { securityStore } from "./stores/security"; | import { securityStore } from "./stores/security"; | ||||||
| import { settingsStore } from "@/stores/settings"; | import { settingsStore } from "@/stores/settings"; | ||||||
|  | @ -11,12 +11,13 @@ const auth = authStore() | ||||||
| const sec = securityStore() | const sec = securityStore() | ||||||
| const settings = settingsStore() | const settings = settingsStore() | ||||||
| 
 | 
 | ||||||
|  | const currentTheme = ref("auto") | ||||||
|  | 
 | ||||||
| onMounted(async () => { | onMounted(async () => { | ||||||
|   console.log("Starting WireGuard Portal frontend..."); |   console.log("Starting WireGuard Portal frontend..."); | ||||||
| 
 | 
 | ||||||
|   // restore theme from localStorage |   // restore theme from localStorage | ||||||
|   const theme = localStorage.getItem('wgTheme') || 'light'; |   switchTheme(getTheme()); | ||||||
|   document.documentElement.setAttribute('data-bs-theme', theme); |  | ||||||
| 
 | 
 | ||||||
|   await sec.LoadSecurityProperties(); |   await sec.LoadSecurityProperties(); | ||||||
|   await auth.LoadProviders(); |   await auth.LoadProviders(); | ||||||
|  | @ -44,10 +45,22 @@ const switchLanguage = function (lang) { | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | const getTheme = function () { | ||||||
|  |   return localStorage.getItem('wgTheme') || 'auto'; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| const switchTheme = function (theme) { | const switchTheme = function (theme) { | ||||||
|   if (document.documentElement.getAttribute('data-bs-theme') !== theme) { |   let bsTheme = theme; | ||||||
|  |   if (theme === 'auto') { | ||||||
|  |     bsTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   currentTheme.value = theme; | ||||||
|  | 
 | ||||||
|  |   if (document.documentElement.getAttribute('data-bs-theme') !== bsTheme) { | ||||||
|  |     console.log("Switching theme to " + theme + " (" + bsTheme + ")"); | ||||||
|     localStorage.setItem('wgTheme', theme); |     localStorage.setItem('wgTheme', theme); | ||||||
|     document.documentElement.setAttribute('data-bs-theme', theme); |     document.documentElement.setAttribute('data-bs-theme', bsTheme); | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -137,20 +150,25 @@ const userDisplayName = computed(() => { | ||||||
|           <div v-if="!auth.IsAuthenticated" class="nav-item"> |           <div v-if="!auth.IsAuthenticated" class="nav-item"> | ||||||
|             <RouterLink :to="{ name: 'login' }" class="nav-link"><i class="fas fa-sign-in-alt fa-sm fa-fw me-2"></i>{{ $t('menu.login') }}</RouterLink> |             <RouterLink :to="{ name: 'login' }" class="nav-link"><i class="fas fa-sign-in-alt fa-sm fa-fw me-2"></i>{{ $t('menu.login') }}</RouterLink> | ||||||
|           </div> |           </div> | ||||||
|           <div class="nav-item dropdown" data-bs-theme="light"> |           <div class="nav-item dropdown" :key="currentTheme"> | ||||||
|             <a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="theme-menu" aria-expanded="false" data-bs-toggle="dropdown" data-bs-display="static" aria-label="Toggle theme"> |             <a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="theme-menu" aria-expanded="false" data-bs-toggle="dropdown" data-bs-display="static" aria-label="Toggle theme"> | ||||||
|               <i class="fa-solid fa-circle-half-stroke"></i> |               <i class="fa-solid fa-circle-half-stroke"></i> | ||||||
|               <span class="d-lg-none ms-2">Toggle theme</span> |               <span class="d-lg-none ms-2">Toggle theme</span> | ||||||
|             </a> |             </a> | ||||||
|             <ul class="dropdown-menu dropdown-menu-end"> |             <ul class="dropdown-menu dropdown-menu-end"> | ||||||
|  |               <li> | ||||||
|  |                 <button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('auto')" aria-pressed="false"> | ||||||
|  |                   <i class="fa-solid fa-circle-half-stroke"></i><span class="ms-2">System</span><i class="fa-solid fa-check ms-5" :class="{invisible:currentTheme!=='auto'}"></i> | ||||||
|  |                 </button> | ||||||
|  |               </li> | ||||||
|               <li> |               <li> | ||||||
|                 <button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('light')" aria-pressed="false"> |                 <button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('light')" aria-pressed="false"> | ||||||
|                   <i class="fa-solid fa-sun"></i><span class="ms-2">Light</span> |                   <i class="fa-solid fa-sun"></i><span class="ms-2">Light</span><i class="fa-solid fa-check ms-5" :class="{invisible:currentTheme!=='light'}"></i> | ||||||
|                 </button> |                 </button> | ||||||
|               </li> |               </li> | ||||||
|               <li> |               <li> | ||||||
|                 <button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('dark')" aria-pressed="true"> |                 <button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('dark')" aria-pressed="true"> | ||||||
|                   <i class="fa-solid fa-moon"></i><span class="ms-2">Dark</span> |                   <i class="fa-solid fa-moon"></i><span class="ms-2">Dark</span><i class="fa-solid fa-check ms-5" :class="{invisible:currentTheme!=='dark'}"></i> | ||||||
|                 </button> |                 </button> | ||||||
|               </li> |               </li> | ||||||
|             </ul> |             </ul> | ||||||
|  | @ -221,4 +239,8 @@ const userDisplayName = computed(() => { | ||||||
|   background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important; |   background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important; | ||||||
|   color: var(--bs-badge-color)!important; |   color: var(--bs-badge-color)!important; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | [data-bs-theme=dark] .navbar-dark, .navbar { | ||||||
|  |   background-color: #000 !important; | ||||||
|  | } | ||||||
| </style> | </style> | ||||||
|  |  | ||||||
|  | @ -105,3 +105,7 @@ a.disabled { | ||||||
| .vue-tags-input .ti-deletion-mark:after { | .vue-tags-input .ti-deletion-mark:after { | ||||||
|   transform: scaleX(1); |   transform: scaleX(1); | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | .modal-dialog { | ||||||
|  |     box-shadow: none; | ||||||
|  | } | ||||||
|  | @ -316,6 +316,16 @@ async function del() { | ||||||
|   isDeleting.value = true |   isDeleting.value = true | ||||||
|   try { |   try { | ||||||
|     await interfaces.DeleteInterface(selectedInterface.value.Identifier) |     await interfaces.DeleteInterface(selectedInterface.value.Identifier) | ||||||
|  | 
 | ||||||
|  |     // reload all interfaces and peers | ||||||
|  |     await interfaces.LoadInterfaces() | ||||||
|  |     if (interfaces.Count > 0 && interfaces.GetSelected !== undefined) { | ||||||
|  |       const selectedInterface = interfaces.GetSelected | ||||||
|  |       await peers.LoadPeers(selectedInterface.Identifier) | ||||||
|  |       await peers.LoadStats(selectedInterface.Identifier) | ||||||
|  |     } else { | ||||||
|  |       await peers.Reset() // reset peers if no interfaces are available | ||||||
|  |     } | ||||||
|     close() |     close() | ||||||
|   } catch (e) { |   } catch (e) { | ||||||
|     console.log(e) |     console.log(e) | ||||||
|  | @ -434,6 +444,11 @@ async function del() { | ||||||
|                 <label class="form-label mt-4">{{ $t('modals.interface-edit.firewall-mark.label') }}</label> |                 <label class="form-label mt-4">{{ $t('modals.interface-edit.firewall-mark.label') }}</label> | ||||||
|                 <input v-model="formData.FirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number"> |                 <input v-model="formData.FirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number"> | ||||||
|               </div> |               </div> | ||||||
|  |               <div class="form-group col-md-6" v-if="formData.Backend!=='local'"> | ||||||
|  |                 <label class="form-label mt-4">{{ $t('modals.interface-edit.routing-table.label') }}</label> | ||||||
|  |                 <input v-model="formData.RoutingTable" aria-describedby="routingTableHelp" class="form-control" :placeholder="$t('modals.interface-edit.routing-table.placeholder')" type="text"> | ||||||
|  |                 <small id="routingTableHelp" class="form-text text-muted">{{ $t('modals.interface-edit.routing-table.description') }}</small> | ||||||
|  |               </div> | ||||||
|               <div class="form-group col-md-6" v-else> |               <div class="form-group col-md-6" v-else> | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|  | @ -447,7 +462,7 @@ async function del() { | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|           </fieldset> |           </fieldset> | ||||||
|           <fieldset> |           <fieldset v-if="formData.Backend==='local'"> | ||||||
|             <legend class="mt-4">{{ $t('modals.interface-edit.header-hooks') }}</legend> |             <legend class="mt-4">{{ $t('modals.interface-edit.header-hooks') }}</legend> | ||||||
|             <div class="form-group"> |             <div class="form-group"> | ||||||
|               <label class="form-label mt-4">{{ $t('modals.interface-edit.pre-up.label') }}</label> |               <label class="form-label mt-4">{{ $t('modals.interface-edit.pre-up.label') }}</label> | ||||||
|  | @ -472,7 +487,7 @@ async function del() { | ||||||
|               <input v-model="formData.Disabled" class="form-check-input" type="checkbox"> |               <input v-model="formData.Disabled" class="form-check-input" type="checkbox"> | ||||||
|               <label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label> |               <label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label> | ||||||
|             </div> |             </div> | ||||||
|             <div class="form-check form-switch"> |             <div class="form-check form-switch" v-if="formData.Backend==='local'"> | ||||||
|               <input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox"> |               <input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox"> | ||||||
|               <label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label> |               <label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|  | @ -358,7 +358,7 @@ async function del() { | ||||||
|           <input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint.placeholder')" |           <input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint.placeholder')" | ||||||
|             v-model="formData.Endpoint.Value"> |             v-model="formData.Endpoint.Value"> | ||||||
|         </div> |         </div> | ||||||
|         <div class="form-group"> |         <div class="form-group" v-if="selectedInterface.Mode !== 'client'"> | ||||||
|           <label class="form-label mt-4">{{ $t('modals.peer-edit.ip.label') }}</label> |           <label class="form-label mt-4">{{ $t('modals.peer-edit.ip.label') }}</label> | ||||||
|           <vue-tags-input class="form-control" v-model="currentTags.Addresses" |           <vue-tags-input class="form-control" v-model="currentTags.Addresses" | ||||||
|                            :tags="formData.Addresses.map(str => ({ text: str }))" |                            :tags="formData.Addresses.map(str => ({ text: str }))" | ||||||
|  |  | ||||||
|  | @ -130,7 +130,7 @@ function ConfigQrUrl() { | ||||||
| <template> | <template> | ||||||
|   <Modal :title="title" :visible="visible" @close="close"> |   <Modal :title="title" :visible="visible" @close="close"> | ||||||
|     <template #default> |     <template #default> | ||||||
|       <div class="d-flex justify-content-end align-items-center mb-1"> |       <div class="d-flex justify-content-end align-items-center mb-1" v-if="selectedInterface.Mode !== 'client'"> | ||||||
|         <span class="me-2">{{ $t('modals.peer-view.style-label') }}: </span> |         <span class="me-2">{{ $t('modals.peer-view.style-label') }}: </span> | ||||||
|         <div class="btn-group btn-switch-group" role="group" aria-label="Configuration Style"> |         <div class="btn-group btn-switch-group" role="group" aria-label="Configuration Style"> | ||||||
|           <input type="radio" class="btn-check" name="configstyle" id="raw" value="raw" autocomplete="off" checked="" v-model="configStyle"> |           <input type="radio" class="btn-check" name="configstyle" id="raw" value="raw" autocomplete="off" checked="" v-model="configStyle"> | ||||||
|  | @ -151,20 +151,28 @@ function ConfigQrUrl() { | ||||||
|             data-bs-parent="#peerInformation" style=""> |             data-bs-parent="#peerInformation" style=""> | ||||||
|             <div class="accordion-body"> |             <div class="accordion-body"> | ||||||
|               <div class="row"> |               <div class="row"> | ||||||
|                 <div class="col-md-8"> |                 <div :class="{ 'col-md-8': selectedInterface.Mode !== 'client',  'col-md-12': selectedInterface.Mode !== 'server' }" class="col-md-8"> | ||||||
|                   <ul> |                   <ul> | ||||||
|                     <li>{{ $t('modals.peer-view.identifier') }}: {{ selectedPeer.PublicKey }}</li> |                     <li v-if="selectedInterface.Mode !== 'client'"><strong>{{ $t('modals.peer-view.identifier') }}</strong>: {{ selectedPeer.PublicKey }}</li> | ||||||
|                     <li>{{ $t('modals.peer-view.ip') }}: <span v-for="ip in selectedPeer.Addresses" :key="ip" |                     <li v-if="selectedInterface.Mode !== 'server'"><strong>{{ $t('modals.peer-view.endpoint-key') }}</strong>: {{ selectedPeer.PublicKey }}</li> | ||||||
|  |                     <li v-if="selectedInterface.Mode !== 'server'"><strong>{{ $t('modals.peer-view.endpoint') }}</strong>: {{ selectedPeer.Endpoint.Value }}</li> | ||||||
|  |                     <li v-if="selectedInterface.Mode !== 'client'"><strong>{{ $t('modals.peer-view.ip') }}</strong>: <span v-for="ip in selectedPeer.Addresses" :key="ip" | ||||||
|                         class="badge rounded-pill bg-light">{{ ip }}</span></li> |                         class="badge rounded-pill bg-light">{{ ip }}</span></li> | ||||||
|                     <li>{{ $t('modals.peer-view.user') }}: {{ selectedPeer.UserIdentifier }}</li> |                     <li v-if="selectedInterface.Mode === 'server'"><strong>{{ $t('modals.peer-view.extra-allowed-ip') }}</strong>: <span v-for="ip in selectedPeer.ExtraAllowedIPs" :key="ip" | ||||||
|                     <li v-if="selectedPeer.Notes">{{ $t('modals.peer-view.notes') }}: {{ selectedPeer.Notes }}</li> |                                                                                                                         class="badge rounded-pill bg-light">{{ ip }}</span></li> | ||||||
|                     <li v-if="selectedPeer.ExpiresAt">{{ $t('modals.peer-view.expiry-status') }}: {{ |                     <li v-if="selectedInterface.Mode !== 'server' && selectedPeer.AllowedIPs.Value"><strong>{{ $t('modals.peer-view.allowed-ip') }}</strong>: <span v-for="ip in selectedPeer.AllowedIPs.Value" :key="ip" | ||||||
|  |                                                                                                           class="badge rounded-pill bg-light">{{ ip }}</span></li> | ||||||
|  |                     <li v-if="selectedInterface.Mode !== 'server'"><strong>{{ $t('modals.peer-view.keepalive') }}</strong>: {{ selectedPeer.PersistentKeepalive.Value }}</li> | ||||||
|  |                     <li v-if="selectedPeer.UserDisplayName"><strong>{{ $t('modals.peer-view.user') }}</strong>: {{ selectedPeer.UserDisplayName }} ({{ selectedPeer.UserIdentifier }})</li> | ||||||
|  |                     <li v-else><strong>{{ $t('modals.peer-view.user') }}</strong>: {{ selectedPeer.UserIdentifier }}</li> | ||||||
|  |                     <li v-if="selectedPeer.Notes"><strong>{{ $t('modals.peer-view.notes') }}</strong>: {{ selectedPeer.Notes }}</li> | ||||||
|  |                     <li v-if="selectedPeer.ExpiresAt"><strong>{{ $t('modals.peer-view.expiry-status') }}</strong>: {{ | ||||||
|                       selectedPeer.ExpiresAt }}</li> |                       selectedPeer.ExpiresAt }}</li> | ||||||
|                     <li v-if="selectedPeer.Disabled">{{ $t('modals.peer-view.disabled-status') }}: {{ |                     <li v-if="selectedPeer.Disabled"><strong>{{ $t('modals.peer-view.disabled-status') }}</strong>: {{ | ||||||
|                       selectedPeer.DisabledReason }}</li> |                       selectedPeer.DisabledReason }}</li> | ||||||
|                   </ul> |                   </ul> | ||||||
|                 </div> |                 </div> | ||||||
|                 <div class="col-md-4"> |                 <div class="col-md-4" v-if="selectedInterface.Mode !== 'client'"> | ||||||
|                   <img class="config-qr-img" :src="ConfigQrUrl()" loading="lazy" alt="Configuration QR Code"> |                   <img class="config-qr-img" :src="ConfigQrUrl()" loading="lazy" alt="Configuration QR Code"> | ||||||
|                 </div> |                 </div> | ||||||
|               </div> |               </div> | ||||||
|  | @ -199,7 +207,7 @@ function ConfigQrUrl() { | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|         <div v-if="selectedInterface.Mode === 'server'" class="accordion-item"> |         <div v-if="selectedInterface.Mode !== 'client'" class="accordion-item"> | ||||||
|           <h2 class="accordion-header" id="headingConfig"> |           <h2 class="accordion-header" id="headingConfig"> | ||||||
|             <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" |             <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | ||||||
|               data-bs-target="#collapseConfig" aria-expanded="false" aria-controls="collapseConfig"> |               data-bs-target="#collapseConfig" aria-expanded="false" aria-controls="collapseConfig"> | ||||||
|  | @ -217,9 +225,9 @@ function ConfigQrUrl() { | ||||||
|     </template> |     </template> | ||||||
|     <template #footer> |     <template #footer> | ||||||
|       <div class="flex-fill text-start"> |       <div class="flex-fill text-start"> | ||||||
|         <button @click.prevent="download" type="button" class="btn btn-primary me-1">{{ |         <button v-if="selectedInterface.Mode !== 'client'" @click.prevent="download" type="button" class="btn btn-primary me-1">{{ | ||||||
|           $t('modals.peer-view.button-download') }}</button> |           $t('modals.peer-view.button-download') }}</button> | ||||||
|         <button @click.prevent="email" type="button" class="btn btn-primary me-1">{{ |         <button v-if="selectedInterface.Mode !== 'client'" @click.prevent="email" type="button" class="btn btn-primary me-1">{{ | ||||||
|           $t('modals.peer-view.button-email') }}</button> |           $t('modals.peer-view.button-email') }}</button> | ||||||
|       </div> |       </div> | ||||||
|       <button @click.prevent="close" type="button" class="btn btn-secondary">{{ $t('general.close') }}</button> |       <button @click.prevent="close" type="button" class="btn btn-secondary">{{ $t('general.close') }}</button> | ||||||
|  |  | ||||||
|  | @ -4,12 +4,12 @@ export function ipToBigInt(ip) { | ||||||
|   // Check if it's an IPv4 address
 |   // Check if it's an IPv4 address
 | ||||||
|   if (ip.includes(".")) { |   if (ip.includes(".")) { | ||||||
|     const addr = new Address4(ip) |     const addr = new Address4(ip) | ||||||
|     return addr.bigInteger() |     return addr.bigInt() | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   // Otherwise, assume it's an IPv6 address
 |   // Otherwise, assume it's an IPv6 address
 | ||||||
|   const addr = new Address6(ip) |   const addr = new Address6(ip) | ||||||
|   return addr.bigInteger() |   return addr.bigInt() | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function humanFileSize(size) { | export function humanFileSize(size) { | ||||||
|  |  | ||||||
|  | @ -117,6 +117,7 @@ | ||||||
|       "dns": "DNS-Server", |       "dns": "DNS-Server", | ||||||
|       "mtu": "MTU", |       "mtu": "MTU", | ||||||
|       "default-keep-alive": "Standard Keepalive-Intervall", |       "default-keep-alive": "Standard Keepalive-Intervall", | ||||||
|  |       "default-dns": "Standard DNS-Server", | ||||||
|       "button-show-config": "Konfiguration anzeigen", |       "button-show-config": "Konfiguration anzeigen", | ||||||
|       "button-download-config": "Konfiguration herunterladen", |       "button-download-config": "Konfiguration herunterladen", | ||||||
|       "button-store-config": "Konfiguration für wg-quick speichern", |       "button-store-config": "Konfiguration für wg-quick speichern", | ||||||
|  | @ -220,6 +221,16 @@ | ||||||
|       "button-delete-text": "Passkey löschen. Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.", |       "button-delete-text": "Passkey löschen. Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.", | ||||||
|       "button-register-title": "Passkey registrieren", |       "button-register-title": "Passkey registrieren", | ||||||
|       "button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern." |       "button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern." | ||||||
|  |     }, | ||||||
|  |     "password": { | ||||||
|  |       "headline": "Passwort-Einstellungen", | ||||||
|  |       "abstract": "Hier können Sie Ihr Passwort ändern.", | ||||||
|  |       "current-label": "Aktuelles Passwort", | ||||||
|  |       "new-label": "Neues Passwort", | ||||||
|  |       "new-confirm-label": "Neues Passwort bestätigen", | ||||||
|  |       "change-button-text": "Passwort ändern", | ||||||
|  |       "invalid-confirm-label": "Passwörter stimmen nicht überein", | ||||||
|  |       "weak-label": "Passwort ist zu schwach" | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   "audit": { |   "audit": { | ||||||
|  | @ -461,6 +472,8 @@ | ||||||
|       "section-config": "Konfiguration", |       "section-config": "Konfiguration", | ||||||
|       "identifier": "Kennung", |       "identifier": "Kennung", | ||||||
|       "ip": "IP-Adressen", |       "ip": "IP-Adressen", | ||||||
|  |       "allowed-ip": "Erlaubte IP-Adressen", | ||||||
|  |       "extra-allowed-ip": "Serverseitig erlaubte IP-Adressen", | ||||||
|       "user": "Zugeordneter Benutzer", |       "user": "Zugeordneter Benutzer", | ||||||
|       "notes": "Notizen", |       "notes": "Notizen", | ||||||
|       "expiry-status": "Läuft ab am", |       "expiry-status": "Läuft ab am", | ||||||
|  | @ -473,6 +486,8 @@ | ||||||
|       "handshake": "Letzter Handshake", |       "handshake": "Letzter Handshake", | ||||||
|       "connected-since": "Verbunden seit", |       "connected-since": "Verbunden seit", | ||||||
|       "endpoint": "Endpunkt", |       "endpoint": "Endpunkt", | ||||||
|  |       "endpoint-key": "Öffentlicher Endpunkt-Schlüssel", | ||||||
|  |       "keepalive": "Persistentes Keepalive", | ||||||
|       "button-download": "Konfiguration herunterladen", |       "button-download": "Konfiguration herunterladen", | ||||||
|       "button-email": "Konfiguration per E-Mail senden", |       "button-email": "Konfiguration per E-Mail senden", | ||||||
|       "style-label": "Konfigurationsformat" |       "style-label": "Konfigurationsformat" | ||||||
|  |  | ||||||
|  | @ -117,6 +117,7 @@ | ||||||
|       "dns": "DNS Servers", |       "dns": "DNS Servers", | ||||||
|       "mtu": "MTU", |       "mtu": "MTU", | ||||||
|       "default-keep-alive": "Default Keepalive Interval", |       "default-keep-alive": "Default Keepalive Interval", | ||||||
|  |       "default-dns": "Default DNS Servers", | ||||||
|       "button-show-config": "Show configuration", |       "button-show-config": "Show configuration", | ||||||
|       "button-download-config": "Download configuration", |       "button-download-config": "Download configuration", | ||||||
|       "button-store-config": "Store configuration for wg-quick", |       "button-store-config": "Store configuration for wg-quick", | ||||||
|  | @ -220,6 +221,16 @@ | ||||||
|       "button-delete-text": "Delete the passkey. You will not be able to log in with this passkey anymore.", |       "button-delete-text": "Delete the passkey. You will not be able to log in with this passkey anymore.", | ||||||
|       "button-register-title": "Register Passkey", |       "button-register-title": "Register Passkey", | ||||||
|       "button-register-text": "Register a new Passkey to secure your account." |       "button-register-text": "Register a new Passkey to secure your account." | ||||||
|  |     }, | ||||||
|  |     "password": { | ||||||
|  |       "headline": "Password Settings", | ||||||
|  |       "abstract": "Here you can change your password.", | ||||||
|  |       "current-label": "Current Password", | ||||||
|  |       "new-label": "New Password", | ||||||
|  |       "new-confirm-label": "Confirm New Password", | ||||||
|  |       "change-button-text": "Change Password", | ||||||
|  |       "invalid-confirm-label": "Passwords do not match", | ||||||
|  |       "weak-label": "Password is too weak" | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   "audit": { |   "audit": { | ||||||
|  | @ -462,6 +473,8 @@ | ||||||
|       "section-config": "Configuration", |       "section-config": "Configuration", | ||||||
|       "identifier": "Identifier", |       "identifier": "Identifier", | ||||||
|       "ip": "IP Addresses", |       "ip": "IP Addresses", | ||||||
|  |       "allowed-ip": "Allowed IP Addresses", | ||||||
|  |       "extra-allowed-ip": "Server Side Allowed IP Addresses", | ||||||
|       "user": "Associated User", |       "user": "Associated User", | ||||||
|       "notes": "Notes", |       "notes": "Notes", | ||||||
|       "expiry-status": "Expires At", |       "expiry-status": "Expires At", | ||||||
|  | @ -474,6 +487,8 @@ | ||||||
|       "handshake": "Last Handshake", |       "handshake": "Last Handshake", | ||||||
|       "connected-since": "Connected since", |       "connected-since": "Connected since", | ||||||
|       "endpoint": "Endpoint", |       "endpoint": "Endpoint", | ||||||
|  |       "endpoint-key": "Endpoint Public Key", | ||||||
|  |       "keepalive": "Persistent Keepalive", | ||||||
|       "button-download": "Download configuration", |       "button-download": "Download configuration", | ||||||
|       "button-email": "Send configuration via E-Mail", |       "button-email": "Send configuration via E-Mail", | ||||||
|       "style-label": "Configuration Style" |       "style-label": "Configuration Style" | ||||||
|  |  | ||||||
|  | @ -115,6 +115,7 @@ export const interfaceStore = defineStore('interfaces', { | ||||||
|       return apiWrapper.post(`${baseUrl}/new`, formData) |       return apiWrapper.post(`${baseUrl}/new`, formData) | ||||||
|         .then(iface => { |         .then(iface => { | ||||||
|           this.interfaces.push(iface) |           this.interfaces.push(iface) | ||||||
|  |           this.selected = iface.Identifier | ||||||
|           this.fetching = false |           this.fetching = false | ||||||
|         }) |         }) | ||||||
|         .catch(error => { |         .catch(error => { | ||||||
|  |  | ||||||
|  | @ -126,9 +126,14 @@ export const peerStore = defineStore('peers', { | ||||||
|       if (!statsResponse) { |       if (!statsResponse) { | ||||||
|         this.stats = {} |         this.stats = {} | ||||||
|         this.statsEnabled = false |         this.statsEnabled = false | ||||||
|       } |       } else { | ||||||
|           this.stats = statsResponse.Stats |           this.stats = statsResponse.Stats | ||||||
|           this.statsEnabled = statsResponse.Enabled |           this.statsEnabled = statsResponse.Enabled | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     async Reset() { | ||||||
|  |       this.setPeers([]) | ||||||
|  |       this.setStats(undefined) | ||||||
|     }, |     }, | ||||||
|     async PreparePeer(interfaceId) { |     async PreparePeer(interfaceId) { | ||||||
|       return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/prepare`) |       return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/prepare`) | ||||||
|  | @ -186,10 +191,10 @@ export const peerStore = defineStore('peers', { | ||||||
|     async LoadStats(interfaceId) { |     async LoadStats(interfaceId) { | ||||||
|       // if no interfaceId is given, use the currently selected interface
 |       // if no interfaceId is given, use the currently selected interface
 | ||||||
|       if (!interfaceId) { |       if (!interfaceId) { | ||||||
|         interfaceId = interfaceStore().GetSelected.Identifier |         if (!interfaceStore().GetSelected || !interfaceStore().GetSelected.Identifier) { | ||||||
|         if (!interfaceId) { |  | ||||||
|             return // no interface, nothing to load
 |             return // no interface, nothing to load
 | ||||||
|         } |         } | ||||||
|  |         interfaceId = interfaceStore().GetSelected.Identifier | ||||||
|       } |       } | ||||||
|       this.fetching = true |       this.fetching = true | ||||||
| 
 | 
 | ||||||
|  | @ -260,10 +265,10 @@ export const peerStore = defineStore('peers', { | ||||||
|     async LoadPeers(interfaceId) { |     async LoadPeers(interfaceId) { | ||||||
|       // if no interfaceId is given, use the currently selected interface
 |       // if no interfaceId is given, use the currently selected interface
 | ||||||
|       if (!interfaceId) { |       if (!interfaceId) { | ||||||
|         interfaceId = interfaceStore().GetSelected.Identifier |         if (!interfaceStore().GetSelected || !interfaceStore().GetSelected.Identifier) { | ||||||
|         if (!interfaceId) { |  | ||||||
|           return // no interface, nothing to load
 |           return // no interface, nothing to load
 | ||||||
|         } |         } | ||||||
|  |         interfaceId = interfaceStore().GetSelected.Identifier | ||||||
|       } |       } | ||||||
|       this.fetching = true |       this.fetching = true | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -151,6 +151,17 @@ export const profileStore = defineStore('profile', { | ||||||
|             }) |             }) | ||||||
|           }) |           }) | ||||||
|     }, |     }, | ||||||
|  |     async changePassword(formData) { | ||||||
|  |       this.fetching = true | ||||||
|  |       let currentUser = authStore().user.Identifier | ||||||
|  |       return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/change-password`, formData) | ||||||
|  |           .then(this.fetching = false) | ||||||
|  |           .catch(error => { | ||||||
|  |             this.fetching = false; | ||||||
|  |             console.log("Failed to change password for ", currentUser, ": ", error); | ||||||
|  |             throw new Error(error); | ||||||
|  |           }); | ||||||
|  |     }, | ||||||
|     async LoadPeers() { |     async LoadPeers() { | ||||||
|       this.fetching = true |       this.fetching = true | ||||||
|       let currentUser = authStore().user.Identifier |       let currentUser = authStore().user.Identifier | ||||||
|  |  | ||||||
|  | @ -217,14 +217,14 @@ onMounted(async () => { | ||||||
|                   <td>{{ $t('interfaces.interface.ip') }}:</td> |                   <td>{{ $t('interfaces.interface.ip') }}:</td> | ||||||
|                   <td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Addresses" :key="addr">{{addr}}</span></td> |                   <td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Addresses" :key="addr">{{addr}}</span></td> | ||||||
|                 </tr> |                 </tr> | ||||||
|                 <tr> |  | ||||||
|                   <td>{{ $t('interfaces.interface.dns') }}:</td> |  | ||||||
|                   <td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Dns" :key="addr">{{addr}}</span></td> |  | ||||||
|                 </tr> |  | ||||||
|                 <tr> |                 <tr> | ||||||
|                   <td>{{ $t('interfaces.interface.mtu') }}:</td> |                   <td>{{ $t('interfaces.interface.mtu') }}:</td> | ||||||
|                   <td>{{interfaces.GetSelected.Mtu}}</td> |                   <td>{{interfaces.GetSelected.Mtu}}</td> | ||||||
|                 </tr> |                 </tr> | ||||||
|  |                 <tr> | ||||||
|  |                   <td>{{ $t('interfaces.interface.default-dns') }}:</td> | ||||||
|  |                   <td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.PeerDefDns" :key="addr">{{addr}}</span></td> | ||||||
|  |                 </tr> | ||||||
|                 <tr> |                 <tr> | ||||||
|                   <td>{{ $t('interfaces.interface.default-keep-alive') }}:</td> |                   <td>{{ $t('interfaces.interface.default-keep-alive') }}:</td> | ||||||
|                   <td>{{interfaces.GetSelected.PeerDefPersistentKeepalive}}</td> |                   <td>{{interfaces.GetSelected.PeerDefPersistentKeepalive}}</td> | ||||||
|  |  | ||||||
|  | @ -1,8 +1,9 @@ | ||||||
| <script setup> | <script setup> | ||||||
| import {onMounted, ref} from "vue"; | import {computed, onMounted, ref} from "vue"; | ||||||
| import { profileStore } from "@/stores/profile"; | import { profileStore } from "@/stores/profile"; | ||||||
| import { settingsStore } from "@/stores/settings"; | import { settingsStore } from "@/stores/settings"; | ||||||
| import { authStore } from "../stores/auth"; | import { authStore } from "../stores/auth"; | ||||||
|  | import {notify} from "@kyvg/vue3-notification"; | ||||||
| 
 | 
 | ||||||
| const profile = profileStore() | const profile = profileStore() | ||||||
| const settings = settingsStore() | const settings = settingsStore() | ||||||
|  | @ -34,6 +35,45 @@ async function saveRename(credential) { | ||||||
|     console.error("Failed to rename credential:", error); |     console.error("Failed to rename credential:", error); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | const pwFormData = ref({ | ||||||
|  |   OldPassword: '', | ||||||
|  |   Password: '', | ||||||
|  |   PasswordRepeat: '', | ||||||
|  | }) | ||||||
|  | 
 | ||||||
|  | const passwordWeak = computed(() => { | ||||||
|  |   return pwFormData.value.Password && pwFormData.value.Password.length > 0 && pwFormData.value.Password.length < settings.Setting('MinPasswordLength') | ||||||
|  | }) | ||||||
|  | 
 | ||||||
|  | const passwordChangeAllowed = computed(() => { | ||||||
|  |   return pwFormData.value.Password && pwFormData.value.Password.length >= settings.Setting('MinPasswordLength') && | ||||||
|  |       pwFormData.value.Password === pwFormData.value.PasswordRepeat && | ||||||
|  |       pwFormData.value.OldPassword && pwFormData.value.OldPassword.length > 0 && pwFormData.value.OldPassword !== pwFormData.value.Password; | ||||||
|  | }) | ||||||
|  | 
 | ||||||
|  | const updatePassword = async () => { | ||||||
|  |   try { | ||||||
|  |     await profile.changePassword(pwFormData.value); | ||||||
|  | 
 | ||||||
|  |     pwFormData.value.OldPassword = ''; | ||||||
|  |     pwFormData.value.Password = ''; | ||||||
|  |     pwFormData.value.PasswordRepeat = ''; | ||||||
|  |     notify({ | ||||||
|  |       title: "Password changed!", | ||||||
|  |       text: "Your password has been changed successfully.", | ||||||
|  |       type: 'success', | ||||||
|  |     }); | ||||||
|  |   } catch (e) { | ||||||
|  |     notify({ | ||||||
|  |       title: "Failed to update password!", | ||||||
|  |       text: e.toString(), | ||||||
|  |       type: 'error', | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|  | @ -43,54 +83,47 @@ async function saveRename(credential) { | ||||||
| 
 | 
 | ||||||
|   <p class="lead">{{ $t('settings.abstract') }}</p> |   <p class="lead">{{ $t('settings.abstract') }}</p> | ||||||
| 
 | 
 | ||||||
|   <div v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')"> |   <div class="card border-secondary p-5 mt-5" v-if="profile.user.Source === 'db'"> | ||||||
|     <div class="card border-secondary p-5" v-if="profile.user.ApiToken"> |     <h2 class="display-7">{{ $t('settings.password.headline') }}</h2> | ||||||
|       <h2 class="display-7">{{ $t('settings.api.headline') }}</h2> |     <p class="lead">{{ $t('settings.password.abstract') }}</p> | ||||||
|       <p class="lead">{{ $t('settings.api.abstract') }}</p> |  | ||||||
|     <hr class="my-4"> |     <hr class="my-4"> | ||||||
|       <p>{{ $t('settings.api.active-description') }}</p> | 
 | ||||||
|     <div class="row"> |     <div class="row"> | ||||||
|       <div class="col-6"> |       <div class="col-6"> | ||||||
|         <div class="form-group"> |         <div class="form-group"> | ||||||
|             <label class="form-label mt-4">{{ $t('settings.api.user-label') }}</label> |           <label class="form-label mt-4" for="oldpw">{{ $t('settings.password.current-label') }}</label> | ||||||
|             <input v-model="profile.user.Identifier" class="form-control" :placeholder="$t('settings.api.user-placeholder')" type="text" readonly> |           <input id="oldpw" v-model="pwFormData.OldPassword" class="form-control" :class="{ 'is-invalid': pwFormData.Password && !pwFormData.OldPassword }" type="password"> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <div class="col-6"> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |     <div class="row"> | ||||||
|  |       <div class="col-6"> | ||||||
|  |         <div class="form-group has-success"> | ||||||
|  |           <label class="form-label mt-4" for="newpw">{{ $t('settings.password.new-label') }}</label> | ||||||
|  |           <input id="newpw" v-model="pwFormData.Password" class="form-control" :class="{ 'is-invalid': passwordWeak,  'is-valid': pwFormData.Password !== '' && !passwordWeak }" type="password"> | ||||||
|  |           <div class="invalid-feedback" v-if="passwordWeak">{{ $t('settings.password.weak-label') }}</div> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|       <div class="col-6"> |       <div class="col-6"> | ||||||
|         <div class="form-group"> |         <div class="form-group"> | ||||||
|             <label class="form-label mt-4">{{ $t('settings.api.token-label') }}</label> |           <label class="form-label mt-4" for="confirmnewpw">{{ $t('settings.password.new-confirm-label') }}</label> | ||||||
|             <input v-model="profile.user.ApiToken" class="form-control" :placeholder="$t('settings.api.token-placeholder')" type="text" readonly> |           <input id="confirmnewpw" v-model="pwFormData.PasswordRepeat" class="form-control" :class="{ 'is-invalid': pwFormData.PasswordRepeat !== ''&& pwFormData.Password !== pwFormData.PasswordRepeat,  'is-valid': pwFormData.PasswordRepeat !== '' && pwFormData.Password === pwFormData.PasswordRepeat && !passwordWeak }" type="password"> | ||||||
|           </div> |           <div class="invalid-feedback" v-if="pwFormData.PasswordRepeat !== ''&& pwFormData.Password !== pwFormData.PasswordRepeat">{{ $t('settings.password.invalid-confirm-label') }}</div> | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|       <div class="row"> |  | ||||||
|         <div class="col-12"> |  | ||||||
|           <div class="form-group"> |  | ||||||
|             <p class="form-label mt-4">{{ $t('settings.api.token-created-label') }} {{profile.user.ApiTokenCreated}}</p> |  | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|     <div class="row mt-5"> |     <div class="row mt-5"> | ||||||
|       <div class="col-6"> |       <div class="col-6"> | ||||||
|           <button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching"> |         <button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="updatePassword" :disabled="profile.isFetching || !passwordChangeAllowed"> | ||||||
|             <i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }} |           <i class="fa-solid fa-floppy-disk"></i> {{ $t('settings.password.change-button-text') }} | ||||||
|         </button> |         </button> | ||||||
|       </div> |       </div> | ||||||
|       <div class="col-6"> |       <div class="col-6"> | ||||||
|           <a href="/api/v1/doc.html" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a> |  | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|     <div class="card border-secondary p-5" v-else> |  | ||||||
|       <h2 class="display-7">{{ $t('settings.api.headline') }}</h2> |  | ||||||
|       <p class="lead">{{ $t('settings.api.abstract') }}</p> |  | ||||||
|       <hr class="my-4"> |  | ||||||
|       <p>{{ $t('settings.api.inactive-description') }}</p> |  | ||||||
|       <button class="btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching"> |  | ||||||
|         <i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }} |  | ||||||
|       </button> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| 
 | 
 | ||||||
|   <div class="card border-secondary p-5 mt-5" v-if="settings.Setting('WebAuthnEnabled')"> |   <div class="card border-secondary p-5 mt-5" v-if="settings.Setting('WebAuthnEnabled')"> | ||||||
|     <h2 class="display-7">{{ $t('settings.webauthn.headline') }}</h2> |     <h2 class="display-7">{{ $t('settings.webauthn.headline') }}</h2> | ||||||
|  | @ -173,4 +206,53 @@ async function saveRename(credential) { | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|   </div> |   </div> | ||||||
|  | 
 | ||||||
|  |   <div class="mt-5" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')"> | ||||||
|  |     <div class="card border-secondary p-5" v-if="profile.user.ApiToken"> | ||||||
|  |       <h2 class="display-7">{{ $t('settings.api.headline') }}</h2> | ||||||
|  |       <p class="lead">{{ $t('settings.api.abstract') }}</p> | ||||||
|  |       <hr class="my-4"> | ||||||
|  |       <p>{{ $t('settings.api.active-description') }}</p> | ||||||
|  |       <div class="row"> | ||||||
|  |         <div class="col-6"> | ||||||
|  |           <div class="form-group"> | ||||||
|  |             <label class="form-label mt-4">{{ $t('settings.api.user-label') }}</label> | ||||||
|  |             <input v-model="profile.user.Identifier" class="form-control" :placeholder="$t('settings.api.user-placeholder')" type="text" readonly> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         <div class="col-6"> | ||||||
|  |           <div class="form-group"> | ||||||
|  |             <label class="form-label mt-4">{{ $t('settings.api.token-label') }}</label> | ||||||
|  |             <input v-model="profile.user.ApiToken" class="form-control" :placeholder="$t('settings.api.token-placeholder')" type="text" readonly> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <div class="row"> | ||||||
|  |         <div class="col-12"> | ||||||
|  |           <div class="form-group"> | ||||||
|  |             <p class="form-label mt-4">{{ $t('settings.api.token-created-label') }} {{profile.user.ApiTokenCreated}}</p> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <div class="row mt-5"> | ||||||
|  |         <div class="col-6"> | ||||||
|  |           <button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching"> | ||||||
|  |             <i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }} | ||||||
|  |           </button> | ||||||
|  |         </div> | ||||||
|  |         <div class="col-6"> | ||||||
|  |           <a href="/api/v1/doc.html" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |     <div class="card border-secondary p-5" v-else> | ||||||
|  |       <h2 class="display-7">{{ $t('settings.api.headline') }}</h2> | ||||||
|  |       <p class="lead">{{ $t('settings.api.abstract') }}</p> | ||||||
|  |       <hr class="my-4"> | ||||||
|  |       <p>{{ $t('settings.api.inactive-description') }}</p> | ||||||
|  |       <button class="btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching"> | ||||||
|  |         <i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }} | ||||||
|  |       </button> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
							
								
								
									
										60
									
								
								go.mod
								
								
								
								
							
							
						
						
									
										60
									
								
								go.mod
								
								
								
								
							|  | @ -5,11 +5,11 @@ go 1.24.0 | ||||||
| require ( | require ( | ||||||
| 	github.com/a8m/envsubst v1.4.3 | 	github.com/a8m/envsubst v1.4.3 | ||||||
| 	github.com/alexedwards/scs/v2 v2.9.0 | 	github.com/alexedwards/scs/v2 v2.9.0 | ||||||
| 	github.com/coreos/go-oidc/v3 v3.15.0 | 	github.com/coreos/go-oidc/v3 v3.16.0 | ||||||
| 	github.com/glebarez/sqlite v1.11.0 | 	github.com/glebarez/sqlite v1.11.0 | ||||||
| 	github.com/go-ldap/ldap/v3 v3.4.11 | 	github.com/go-ldap/ldap/v3 v3.4.12 | ||||||
| 	github.com/go-pkgz/routegroup v1.5.3 | 	github.com/go-pkgz/routegroup v1.5.3 | ||||||
| 	github.com/go-playground/validator/v10 v10.27.0 | 	github.com/go-playground/validator/v10 v10.28.0 | ||||||
| 	github.com/go-webauthn/webauthn v0.14.0 | 	github.com/go-webauthn/webauthn v0.14.0 | ||||||
| 	github.com/google/uuid v1.6.0 | 	github.com/google/uuid v1.6.0 | ||||||
| 	github.com/prometheus-community/pro-bing v0.7.0 | 	github.com/prometheus-community/pro-bing v0.7.0 | ||||||
|  | @ -21,9 +21,9 @@ require ( | ||||||
| 	github.com/xhit/go-simple-mail/v2 v2.16.0 | 	github.com/xhit/go-simple-mail/v2 v2.16.0 | ||||||
| 	github.com/yeqown/go-qrcode/v2 v2.2.5 | 	github.com/yeqown/go-qrcode/v2 v2.2.5 | ||||||
| 	github.com/yeqown/go-qrcode/writer/compressed v1.0.1 | 	github.com/yeqown/go-qrcode/writer/compressed v1.0.1 | ||||||
| 	golang.org/x/crypto v0.42.0 | 	golang.org/x/crypto v0.43.0 | ||||||
| 	golang.org/x/oauth2 v0.31.0 | 	golang.org/x/oauth2 v0.32.0 | ||||||
| 	golang.org/x/sys v0.36.0 | 	golang.org/x/sys v0.37.0 | ||||||
| 	golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 | 	golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 | ||||||
| 	gopkg.in/yaml.v3 v3.0.1 | 	gopkg.in/yaml.v3 v3.0.1 | ||||||
| 	gorm.io/driver/mysql v1.6.0 | 	gorm.io/driver/mysql v1.6.0 | ||||||
|  | @ -44,22 +44,17 @@ require ( | ||||||
| 	github.com/gabriel-vasile/mimetype v1.4.10 // indirect | 	github.com/gabriel-vasile/mimetype v1.4.10 // indirect | ||||||
| 	github.com/glebarez/go-sqlite v1.22.0 // indirect | 	github.com/glebarez/go-sqlite v1.22.0 // indirect | ||||||
| 	github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect | 	github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect | ||||||
| 	github.com/go-jose/go-jose/v4 v4.1.2 // indirect | 	github.com/go-jose/go-jose/v4 v4.1.3 // indirect | ||||||
| 	github.com/go-openapi/jsonpointer v0.22.0 // indirect | 	github.com/go-openapi/jsonpointer v0.22.1 // indirect | ||||||
| 	github.com/go-openapi/jsonreference v0.21.1 // indirect | 	github.com/go-openapi/jsonreference v0.21.2 // indirect | ||||||
| 	github.com/go-openapi/spec v0.21.0 // indirect | 	github.com/go-openapi/spec v0.22.0 // indirect | ||||||
| 	github.com/go-openapi/swag v0.24.1 // indirect | 	github.com/go-openapi/swag/conv v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/cmdutils v0.24.0 // indirect | 	github.com/go-openapi/swag/jsonname v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/conv v0.24.0 // indirect | 	github.com/go-openapi/swag/jsonutils v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/fileutils v0.24.0 // indirect | 	github.com/go-openapi/swag/loading v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/jsonname v0.24.0 // indirect | 	github.com/go-openapi/swag/stringutils v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/jsonutils v0.24.0 // indirect | 	github.com/go-openapi/swag/typeutils v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/loading v0.24.0 // indirect | 	github.com/go-openapi/swag/yamlutils v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/mangling v0.24.0 // indirect |  | ||||||
| 	github.com/go-openapi/swag/netutils v0.24.0 // indirect |  | ||||||
| 	github.com/go-openapi/swag/stringutils v0.24.0 // indirect |  | ||||||
| 	github.com/go-openapi/swag/typeutils v0.24.0 // indirect |  | ||||||
| 	github.com/go-openapi/swag/yamlutils v0.24.0 // indirect |  | ||||||
| 	github.com/go-playground/locales v0.14.1 // indirect | 	github.com/go-playground/locales v0.14.1 // indirect | ||||||
| 	github.com/go-playground/universal-translator v0.18.1 // indirect | 	github.com/go-playground/universal-translator v0.18.1 // indirect | ||||||
| 	github.com/go-sql-driver/mysql v1.9.3 // indirect | 	github.com/go-sql-driver/mysql v1.9.3 // indirect | ||||||
|  | @ -69,16 +64,14 @@ require ( | ||||||
| 	github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect | 	github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect | ||||||
| 	github.com/golang-sql/sqlexp v0.1.0 // indirect | 	github.com/golang-sql/sqlexp v0.1.0 // indirect | ||||||
| 	github.com/google/go-cmp v0.7.0 // indirect | 	github.com/google/go-cmp v0.7.0 // indirect | ||||||
| 	github.com/google/go-tpm v0.9.5 // indirect | 	github.com/google/go-tpm v0.9.6 // indirect | ||||||
| 	github.com/jackc/pgpassfile v1.0.0 // indirect | 	github.com/jackc/pgpassfile v1.0.0 // indirect | ||||||
| 	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect | 	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect | ||||||
| 	github.com/jackc/pgx/v5 v5.7.6 // indirect | 	github.com/jackc/pgx/v5 v5.7.6 // indirect | ||||||
| 	github.com/jackc/puddle/v2 v2.2.2 // indirect | 	github.com/jackc/puddle/v2 v2.2.2 // indirect | ||||||
| 	github.com/jinzhu/inflection v1.0.0 // indirect | 	github.com/jinzhu/inflection v1.0.0 // indirect | ||||||
| 	github.com/jinzhu/now v1.1.5 // indirect | 	github.com/jinzhu/now v1.1.5 // indirect | ||||||
| 	github.com/josharian/intern v1.0.0 // indirect |  | ||||||
| 	github.com/leodido/go-urn v1.4.0 // indirect | 	github.com/leodido/go-urn v1.4.0 // indirect | ||||||
| 	github.com/mailru/easyjson v0.9.0 // indirect |  | ||||||
| 	github.com/mattn/go-isatty v0.0.20 // indirect | 	github.com/mattn/go-isatty v0.0.20 // indirect | ||||||
| 	github.com/mdlayher/genetlink v1.3.2 // indirect | 	github.com/mdlayher/genetlink v1.3.2 // indirect | ||||||
| 	github.com/mdlayher/netlink v1.8.0 // indirect | 	github.com/mdlayher/netlink v1.8.0 // indirect | ||||||
|  | @ -96,18 +89,19 @@ require ( | ||||||
| 	github.com/vishvananda/netns v0.0.5 // indirect | 	github.com/vishvananda/netns v0.0.5 // indirect | ||||||
| 	github.com/x448/float16 v0.8.4 // indirect | 	github.com/x448/float16 v0.8.4 // indirect | ||||||
| 	github.com/yeqown/reedsolomon v1.0.0 // indirect | 	github.com/yeqown/reedsolomon v1.0.0 // indirect | ||||||
| 	go.yaml.in/yaml/v2 v2.4.2 // indirect | 	go.yaml.in/yaml/v2 v2.4.3 // indirect | ||||||
| 	golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect | 	go.yaml.in/yaml/v3 v3.0.4 // indirect | ||||||
|  | 	golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect | ||||||
| 	golang.org/x/mod v0.28.0 // indirect | 	golang.org/x/mod v0.28.0 // indirect | ||||||
| 	golang.org/x/net v0.43.0 // indirect | 	golang.org/x/net v0.45.0 // indirect | ||||||
| 	golang.org/x/sync v0.17.0 // indirect | 	golang.org/x/sync v0.17.0 // indirect | ||||||
| 	golang.org/x/text v0.29.0 // indirect | 	golang.org/x/text v0.30.0 // indirect | ||||||
| 	golang.org/x/tools v0.36.0 // indirect | 	golang.org/x/tools v0.37.0 // indirect | ||||||
| 	golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect | 	golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect | ||||||
| 	google.golang.org/protobuf v1.36.8 // indirect | 	google.golang.org/protobuf v1.36.10 // indirect | ||||||
| 	modernc.org/libc v1.66.8 // indirect | 	modernc.org/libc v1.66.10 // indirect | ||||||
| 	modernc.org/mathutil v1.7.1 // indirect | 	modernc.org/mathutil v1.7.1 // indirect | ||||||
| 	modernc.org/memory v1.11.0 // indirect | 	modernc.org/memory v1.11.0 // indirect | ||||||
| 	modernc.org/sqlite v1.38.2 // indirect | 	modernc.org/sqlite v1.39.0 // indirect | ||||||
| 	sigs.k8s.io/yaml v1.6.0 // indirect | 	sigs.k8s.io/yaml v1.6.0 // indirect | ||||||
| ) | ) | ||||||
|  |  | ||||||
							
								
								
									
										137
									
								
								go.sum
								
								
								
								
							
							
						
						
									
										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/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= | ||||||
| github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY= | github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY= | ||||||
| github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU= | github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU= | ||||||
| github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= | github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= | ||||||
| github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= | github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= | ||||||
| github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= | github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= | ||||||
| github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= | github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= | ||||||
| github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= | ||||||
| github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= | ||||||
| github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= | ||||||
| github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | ||||||
| github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg= | github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow= | ||||||
| github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= | github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= | ||||||
| github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= | ||||||
| github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||||
| github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||||||
|  | @ -58,40 +58,33 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM | ||||||
| github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= | github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= | ||||||
| github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= | ||||||
| github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= | ||||||
| github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= | github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= | ||||||
| github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= | github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= | ||||||
| github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= | github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= | ||||||
| github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= | github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= | ||||||
| github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM= | github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= | ||||||
| github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU= | github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= | ||||||
| github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA= | github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= | ||||||
| github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8= | github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= | ||||||
| github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= | github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= | ||||||
| github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= | github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= | ||||||
| github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8= | github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= | ||||||
| github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A= | github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= | ||||||
| github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I= | github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= | ||||||
| github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8= | github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= | ||||||
| github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik= | github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= | ||||||
| github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c= | github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= | ||||||
| github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak= | github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= | ||||||
| github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90= | github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= | ||||||
| github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k= | github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= | ||||||
| github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q= | github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= | ||||||
| github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts= | github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= | ||||||
| github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0= | github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= | ||||||
| github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc= | github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= | ||||||
| github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk= | github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= | ||||||
| github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk= | github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= | ||||||
| github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc= | github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= | ||||||
| github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w= | github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= | ||||||
| github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM= |  | ||||||
| github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM= |  | ||||||
| github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w= |  | ||||||
| github.com/go-openapi/swag/typeutils v0.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw= |  | ||||||
| github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI= |  | ||||||
| github.com/go-openapi/swag/yamlutils v0.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c= |  | ||||||
| github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8= |  | ||||||
| github.com/go-pkgz/routegroup v1.5.3 h1:IvH1KLcQkMap9jucQGBlef3IBloxSAe8USUFvxShFqs= | github.com/go-pkgz/routegroup v1.5.3 h1:IvH1KLcQkMap9jucQGBlef3IBloxSAe8USUFvxShFqs= | ||||||
| github.com/go-pkgz/routegroup v1.5.3/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg= | github.com/go-pkgz/routegroup v1.5.3/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg= | ||||||
| github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | ||||||
|  | @ -100,8 +93,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o | ||||||
| github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= | ||||||
| github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= | ||||||
| github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= | ||||||
| github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= | github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= | ||||||
| github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= | github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= | ||||||
| github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= | github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= | ||||||
| github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= | github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= | ||||||
| github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= | github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= | ||||||
|  | @ -122,8 +115,8 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO | ||||||
| github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= | ||||||
| github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | ||||||
| github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | ||||||
| github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= | github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA= | ||||||
| github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= | github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= | ||||||
| github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= | ||||||
| github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= | ||||||
| github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||||||
|  | @ -158,8 +151,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD | ||||||
| github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= | ||||||
| github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= | ||||||
| github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= | ||||||
| github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= |  | ||||||
| github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= |  | ||||||
| github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= | ||||||
| github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= | ||||||
| github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= | ||||||
|  | @ -173,8 +164,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 | ||||||
| github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= | ||||||
| github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= | ||||||
| github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= | ||||||
| github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= |  | ||||||
| github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= |  | ||||||
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | ||||||
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | ||||||
| github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= | github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= | ||||||
|  | @ -257,10 +246,10 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | ||||||
| go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | ||||||
| go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= | go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= | ||||||
| go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= | go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= | ||||||
| go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= | go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= | ||||||
| go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= | go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= | ||||||
| go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | ||||||
| go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | ||||||
| golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||||
| golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= | ||||||
| golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= | golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= | ||||||
|  | @ -273,10 +262,10 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM | ||||||
| golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= | golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= | ||||||
| golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= | ||||||
| golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= | golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= | ||||||
| golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= | golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= | ||||||
| golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= | golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= | ||||||
| golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= | golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A= | ||||||
| golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= | golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= | ||||||
| golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= | ||||||
| golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= | ||||||
| golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= | golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= | ||||||
|  | @ -302,10 +291,10 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= | ||||||
| golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= | ||||||
| golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= | ||||||
| golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= | ||||||
| golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= | golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= | ||||||
| golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= | golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= | ||||||
| golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= | golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= | ||||||
| golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= | golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= | ||||||
| golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
| golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
| golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
|  | @ -335,8 +324,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||||
| golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||||
| golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||||
| golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||||
| golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= | golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= | ||||||
| golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | ||||||
| golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= | ||||||
| golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | ||||||
| golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= | ||||||
|  | @ -365,23 +354,23 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= | ||||||
| golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= | ||||||
| golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= | ||||||
| golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= | golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= | ||||||
| golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= | golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= | ||||||
| golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= | golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= | ||||||
| golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||||
| golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= | ||||||
| golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= | ||||||
| golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= | ||||||
| golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= | ||||||
| golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= | ||||||
| golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= | golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= | ||||||
| golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= | golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= | ||||||
| golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||||
| golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= | golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= | ||||||
| golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= | golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= | ||||||
| golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= | golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= | ||||||
| golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= | golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= | ||||||
| google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= | google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= | ||||||
| google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= | google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= | ||||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||||
| gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | ||||||
| gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | ||||||
|  | @ -401,18 +390,18 @@ gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQ | ||||||
| gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= | gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= | ||||||
| gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= | gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= | ||||||
| gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= | gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= | ||||||
| modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s= | modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= | ||||||
| modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= | modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= | ||||||
| modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= | modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= | ||||||
| modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= | modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= | ||||||
| modernc.org/fileutil v1.3.28 h1:Vp156KUA2nPu9F1NEv036x9UGOjg2qsi5QlWTjZmtMk= | modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= | ||||||
| modernc.org/fileutil v1.3.28/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= | modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= | ||||||
| modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= | ||||||
| modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= | ||||||
| modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= | modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= | ||||||
| modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= | modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= | ||||||
| modernc.org/libc v1.66.8 h1:/awsvTnyN/sNjvJm6S3lb7KZw5WV4ly/sBEG7ZUzmIE= | modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= | ||||||
| modernc.org/libc v1.66.8/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k= | modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= | ||||||
| modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= | ||||||
| modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= | ||||||
| modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= | ||||||
|  | @ -421,8 +410,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= | ||||||
| modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= | ||||||
| modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= | ||||||
| modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= | ||||||
| modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= | modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= | ||||||
| modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= | modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= | ||||||
| modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= | ||||||
| modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= | ||||||
| modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= | ||||||
|  |  | ||||||
|  | @ -9,6 +9,7 @@ import ( | ||||||
| 	"log/slog" | 	"log/slog" | ||||||
| 	"os" | 	"os" | ||||||
| 	"os/exec" | 	"os/exec" | ||||||
|  | 	"slices" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
|  | @ -85,7 +86,7 @@ func NewLocalController(cfg *config.Config) (*LocalController, error) { | ||||||
| 		nl: nl, | 		nl: nl, | ||||||
| 
 | 
 | ||||||
| 		shellCmd:              "bash",                            // we only support bash at the moment
 | 		shellCmd:              "bash",                            // we only support bash at the moment
 | ||||||
| 		resolvConfIfacePrefix: "tun.", // WireGuard interfaces have a tun. prefix in resolvconf
 | 		resolvConfIfacePrefix: cfg.Backend.LocalResolvconfPrefix, // WireGuard interfaces have a tun. prefix in resolvconf
 | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return repo, nil | 	return repo, nil | ||||||
|  | @ -546,7 +547,11 @@ func (c LocalController) deletePeer(deviceId domain.InterfaceIdentifier, id doma | ||||||
| 
 | 
 | ||||||
| // region wg-quick-related
 | // region wg-quick-related
 | ||||||
| 
 | 
 | ||||||
| func (c LocalController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error { | func (c LocalController) ExecuteInterfaceHook( | ||||||
|  | 	_ context.Context, | ||||||
|  | 	id domain.InterfaceIdentifier, | ||||||
|  | 	hookCmd string, | ||||||
|  | ) error { | ||||||
| 	if hookCmd == "" { | 	if hookCmd == "" { | ||||||
| 		return nil | 		return nil | ||||||
| 	} | 	} | ||||||
|  | @ -560,7 +565,7 @@ func (c LocalController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hoo | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c LocalController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error { | func (c LocalController) SetDNS(_ context.Context, id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error { | ||||||
| 	if dnsStr == "" && dnsSearchStr == "" { | 	if dnsStr == "" && dnsSearchStr == "" { | ||||||
| 		return nil | 		return nil | ||||||
| 	} | 	} | ||||||
|  | @ -589,7 +594,7 @@ func (c LocalController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearch | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c LocalController) UnsetDNS(id domain.InterfaceIdentifier) error { | func (c LocalController) UnsetDNS(_ context.Context, id domain.InterfaceIdentifier, _, _ string) error { | ||||||
| 	dnsCommand := "resolvconf -d %resPref%i -f" | 	dnsCommand := "resolvconf -d %resPref%i -f" | ||||||
| 
 | 
 | ||||||
| 	err := c.exec(dnsCommand, id) | 	err := c.exec(dnsCommand, id) | ||||||
|  | @ -611,7 +616,7 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti | ||||||
| 	if len(stdin) > 0 { | 	if len(stdin) > 0 { | ||||||
| 		b := &bytes.Buffer{} | 		b := &bytes.Buffer{} | ||||||
| 		for _, ln := range stdin { | 		for _, ln := range stdin { | ||||||
| 			if _, err := fmt.Fprint(b, ln); err != nil { | 			if _, err := fmt.Fprint(b, ln+"\n"); err != nil { | ||||||
| 				return err | 				return err | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | @ -619,6 +624,8 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti | ||||||
| 	} | 	} | ||||||
| 	out, err := cmd.CombinedOutput() // execute and wait for output
 | 	out, err := cmd.CombinedOutput() // execute and wait for output
 | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | 		slog.Warn("failed to executed shell command", | ||||||
|  | 			"command", commandWithInterfaceName, "stdin", stdin, "output", string(out), "error", err) | ||||||
| 		return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err) | 		return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err) | ||||||
| 	} | 	} | ||||||
| 	slog.Debug("executed shell command", | 	slog.Debug("executed shell command", | ||||||
|  | @ -631,49 +638,116 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti | ||||||
| 
 | 
 | ||||||
| // region routing-related
 | // region routing-related
 | ||||||
| 
 | 
 | ||||||
| func (c LocalController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error { | // SetRoutes sets the routes for the given interface. If no routes are provided, the function is a no-op.
 | ||||||
| 	// update fwmark rules
 | func (c LocalController) SetRoutes(_ context.Context, info domain.RoutingTableInfo) error { | ||||||
| 	if err := c.setFwMarkRules(rules); err != nil { | 	interfaceId := info.Interface.Identifier | ||||||
| 		return err | 	slog.Debug("setting linux routes", "interface", interfaceId, "table", info.Table, "fwMark", info.FwMark, | ||||||
|  | 		"cidrs", info.AllowedIps) | ||||||
|  | 
 | ||||||
|  | 	link, err := c.nl.LinkByName(string(interfaceId)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to find physical link for %s: %w", interfaceId, err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// update main rule
 | 	cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps) | ||||||
| 	if err := c.setMainRule(rules); err != nil { | 	realTable, realFwMark, err := c.getOrCreateRoutingTableAndFwMark(link, info.Table, info.FwMark) | ||||||
| 		return err | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to get or create routing table and fwmark for %s: %w", interfaceId, err) | ||||||
|  | 	} | ||||||
|  | 	wgDev, err := c.wg.Device(string(interfaceId)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to get wg device for %s: %w", interfaceId, err) | ||||||
|  | 	} | ||||||
|  | 	currentFwMark := wgDev.FirewallMark | ||||||
|  | 	if int(realFwMark) != currentFwMark { | ||||||
|  | 		slog.Debug("updating fwmark for interface", "interface", interfaceId, "oldFwMark", currentFwMark, | ||||||
|  | 			"newFwMark", realFwMark, "oldTable", info.Table, "newTable", realTable) | ||||||
|  | 		if err := c.updateFwMarkOnInterface(interfaceId, int(realFwMark)); err != nil { | ||||||
|  | 			return fmt.Errorf("failed to update fwmark for interface %s to %d: %w", interfaceId, realFwMark, err) | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// cleanup old main rules
 | 	if err := c.setRoutesForFamily(interfaceId, link, netlink.FAMILY_V4, realTable, realFwMark, cidrsV4); err != nil { | ||||||
| 	if err := c.cleanupMainRule(rules); err != nil { | 		return fmt.Errorf("failed to set v4 routes: %w", err) | ||||||
| 		return err | 	} | ||||||
|  | 	if err := c.setRoutesForFamily(interfaceId, link, netlink.FAMILY_V6, realTable, realFwMark, cidrsV6); err != nil { | ||||||
|  | 		return fmt.Errorf("failed to set v6 routes: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c LocalController) setFwMarkRules(rules []domain.RouteRule) error { | func (c LocalController) setRoutesForFamily( | ||||||
| 	for _, rule := range rules { | 	interfaceId domain.InterfaceIdentifier, | ||||||
| 		existingRules, err := c.nl.RuleList(int(rule.IpFamily)) | 	link netlink.Link, | ||||||
|  | 	family int, | ||||||
|  | 	table int, | ||||||
|  | 	fwMark uint32, | ||||||
|  | 	cidrs []domain.Cidr, | ||||||
|  | ) error { | ||||||
|  | 	// first create or update the routes
 | ||||||
|  | 	for _, cidr := range cidrs { | ||||||
|  | 		err := c.nl.RouteReplace(&netlink.Route{ | ||||||
|  | 			LinkIndex: link.Attrs().Index, | ||||||
|  | 			Dst:       cidr.IpNet(), | ||||||
|  | 			Table:     table, | ||||||
|  | 			Scope:     unix.RT_SCOPE_LINK, | ||||||
|  | 			Type:      unix.RTN_UNICAST, | ||||||
|  | 		}) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return fmt.Errorf("failed to get existing rules for family %s: %w", rule.IpFamily, err) | 			return fmt.Errorf("failed to add/update route %s on table %d for interface %s: %w", | ||||||
| 		} | 				cidr.String(), table, interfaceId, err) | ||||||
| 
 |  | ||||||
| 		ruleExists := false |  | ||||||
| 		for _, existingRule := range existingRules { |  | ||||||
| 			if rule.FwMark == existingRule.Mark && rule.Table == existingRule.Table { |  | ||||||
| 				ruleExists = true |  | ||||||
| 				break |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 		if ruleExists { | 	// next remove old routes
 | ||||||
| 			continue // rule already exists, no need to recreate it
 | 	rawRoutes, err := c.nl.RouteListFiltered(family, &netlink.Route{ | ||||||
|  | 		LinkIndex: link.Attrs().Index, | ||||||
|  | 		Table:     unix.RT_TABLE_UNSPEC, // all tables
 | ||||||
|  | 		Scope:     unix.RT_SCOPE_LINK, | ||||||
|  | 		Type:      unix.RTN_UNICAST, | ||||||
|  | 	}, netlink.RT_FILTER_TABLE|netlink.RT_FILTER_TYPE|netlink.RT_FILTER_OIF) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to fetch raw routes for interface %s and family-id %d: %w", | ||||||
|  | 			interfaceId, family, err) | ||||||
|  | 	} | ||||||
|  | 	for _, rawRoute := range rawRoutes { | ||||||
|  | 		if rawRoute.Dst == nil { // handle default route
 | ||||||
|  | 			var netlinkAddr domain.Cidr | ||||||
|  | 			if family == netlink.FAMILY_V4 { | ||||||
|  | 				netlinkAddr, _ = domain.CidrFromString("0.0.0.0/0") | ||||||
|  | 			} else { | ||||||
|  | 				netlinkAddr, _ = domain.CidrFromString("::/0") | ||||||
|  | 			} | ||||||
|  | 			rawRoute.Dst = netlinkAddr.IpNet() | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		// create a missing rule
 | 		route := domain.CidrFromIpNet(*rawRoute.Dst) | ||||||
|  | 		if slices.Contains(cidrs, route) { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if err := c.nl.RouteDel(&rawRoute); err != nil { | ||||||
|  | 			return fmt.Errorf("failed to remove deprecated route %s from interface %s: %w", route, interfaceId, err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// next, update route rules for normal routes
 | ||||||
|  | 	if table == 0 { | ||||||
|  | 		return nil // no need to update route rules as we are using the default table
 | ||||||
|  | 	} | ||||||
|  | 	existingRules, err := c.nl.RuleList(family) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to get existing rules for family-id %d: %w", family, err) | ||||||
|  | 	} | ||||||
|  | 	ruleExists := slices.ContainsFunc(existingRules, func(rule netlink.Rule) bool { | ||||||
|  | 		return rule.Mark == fwMark && rule.Table == table | ||||||
|  | 	}) | ||||||
|  | 	if !ruleExists { | ||||||
| 		if err := c.nl.RuleAdd(&netlink.Rule{ | 		if err := c.nl.RuleAdd(&netlink.Rule{ | ||||||
| 			Family:            int(rule.IpFamily), | 			Family:            family, | ||||||
| 			Table:             rule.Table, | 			Table:             table, | ||||||
| 			Mark:              rule.FwMark, | 			Mark:              fwMark, | ||||||
| 			Invert:            true, | 			Invert:            true, | ||||||
| 			SuppressIfgroup:   -1, | 			SuppressIfgroup:   -1, | ||||||
| 			SuppressPrefixlen: -1, | 			SuppressPrefixlen: -1, | ||||||
|  | @ -682,15 +756,102 @@ func (c LocalController) setFwMarkRules(rules []domain.RouteRule) error { | ||||||
| 			Goto:              -1, | 			Goto:              -1, | ||||||
| 			Flow:              -1, | 			Flow:              -1, | ||||||
| 		}); err != nil { | 		}); err != nil { | ||||||
| 			return fmt.Errorf("failed to setup %s rule for fwmark %d and table %d: %w", | 			return fmt.Errorf("failed to setup rule for fwmark %d and table %d for family-id %d: %w", | ||||||
| 				rule.IpFamily, rule.FwMark, rule.Table, err) | 				fwMark, table, family, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 	mainRuleExists := slices.ContainsFunc(existingRules, func(rule netlink.Rule) bool { | ||||||
|  | 		return rule.SuppressPrefixlen == 0 && rule.Table == unix.RT_TABLE_MAIN | ||||||
|  | 	}) | ||||||
|  | 	if !mainRuleExists && domain.ContainsDefaultRoute(cidrs) { | ||||||
|  | 		err = c.nl.RuleAdd(&netlink.Rule{ | ||||||
|  | 			Family:            family, | ||||||
|  | 			Table:             unix.RT_TABLE_MAIN, | ||||||
|  | 			SuppressIfgroup:   -1, | ||||||
|  | 			SuppressPrefixlen: 0, | ||||||
|  | 			Priority:          c.getMainRulePriority(existingRules), | ||||||
|  | 			Mark:              0, | ||||||
|  | 			Mask:              nil, | ||||||
|  | 			Goto:              -1, | ||||||
|  | 			Flow:              -1, | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// finally, clean up extra main rules - only one rule is allowed
 | ||||||
|  | 	existingRules, err = c.nl.RuleList(family) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to get existing main rules for family-id %d: %w", family, err) | ||||||
|  | 	} | ||||||
|  | 	mainRuleCount := 0 | ||||||
|  | 	for _, rule := range existingRules { | ||||||
|  | 		if rule.SuppressPrefixlen == 0 && rule.Table == unix.RT_TABLE_MAIN { | ||||||
|  | 			mainRuleCount++ | ||||||
|  | 		} | ||||||
|  | 		if mainRuleCount > 1 { | ||||||
|  | 			if err := c.nl.RuleDel(&rule); err != nil { | ||||||
|  | 				return fmt.Errorf("failed to remove extra main rule for family-id %d: %w", family, err) | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (c LocalController) getOrCreateRoutingTableAndFwMark( | ||||||
|  | 	link netlink.Link, | ||||||
|  | 	tableIn int, | ||||||
|  | 	fwMarkIn uint32, | ||||||
|  | ) ( | ||||||
|  | 	table int, | ||||||
|  | 	fwmark uint32, | ||||||
|  | 	err error, | ||||||
|  | ) { | ||||||
|  | 	table = tableIn | ||||||
|  | 	fwmark = fwMarkIn | ||||||
|  | 
 | ||||||
|  | 	if fwmark == 0 { | ||||||
|  | 		// generate a new (temporary) firewall mark based on the interface index
 | ||||||
|  | 		fwmark = uint32(c.cfg.Advanced.RouteTableOffset + link.Attrs().Index) | ||||||
|  | 	} | ||||||
|  | 	if table == 0 { | ||||||
|  | 		table = int(fwmark) // generate a new routing table base on interface index
 | ||||||
|  | 	} | ||||||
|  | 	return | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (c LocalController) updateFwMarkOnInterface(interfaceId domain.InterfaceIdentifier, fwMark int) error { | ||||||
|  | 	// apply the new fwmark to the wireguard interface
 | ||||||
|  | 	err := c.wg.ConfigureDevice(string(interfaceId), wgtypes.Config{ | ||||||
|  | 		FirewallMark: &fwMark, | ||||||
|  | 	}) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to update fwmark of interface %s to: %d: %w", interfaceId, fwMark, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (c LocalController) getMainRulePriority(existingRules []netlink.Rule) int { | ||||||
|  | 	prio := c.cfg.Advanced.RulePrioOffset | ||||||
|  | 	for { | ||||||
|  | 		isFresh := true | ||||||
|  | 		for _, existingRule := range existingRules { | ||||||
|  | 			if existingRule.Priority == prio { | ||||||
|  | 				isFresh = false | ||||||
|  | 				break | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		if isFresh { | ||||||
|  | 			break | ||||||
|  | 		} else { | ||||||
|  | 			prio++ | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return prio | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (c LocalController) getRulePriority(existingRules []netlink.Rule) int { | func (c LocalController) getRulePriority(existingRules []netlink.Rule) int { | ||||||
| 	prio := 32700 // linux main rule has a priority of 32766
 | 	prio := 32700 // linux main rule has a prio of 32766
 | ||||||
| 	for { | 	for { | ||||||
| 		isFresh := true | 		isFresh := true | ||||||
| 		for _, existingRule := range existingRules { | 		for _, existingRule := range existingRules { | ||||||
|  | @ -708,126 +869,145 @@ func (c LocalController) getRulePriority(existingRules []netlink.Rule) int { | ||||||
| 	return prio | 	return prio | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c LocalController) setMainRule(rules []domain.RouteRule) error { | // RemoveRoutes removes the routes for the given interface. If no routes are provided, the function is a no-op.
 | ||||||
| 	var family domain.IpFamily | func (c LocalController) RemoveRoutes(_ context.Context, info domain.RoutingTableInfo) error { | ||||||
| 	shouldHaveMainRule := false | 	interfaceId := info.Interface.Identifier | ||||||
| 	for _, rule := range rules { | 	slog.Debug("removing linux routes", "interface", interfaceId, "table", info.Table, "fwMark", info.FwMark, | ||||||
| 		family = rule.IpFamily | 		"cidrs", info.AllowedIps) | ||||||
| 		if rule.HasDefault == true { |  | ||||||
| 			shouldHaveMainRule = true |  | ||||||
| 			break |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	if !shouldHaveMainRule { |  | ||||||
| 		return nil |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	existingRules, err := c.nl.RuleList(int(family)) | 	wgDev, err := c.wg.Device(string(interfaceId)) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fmt.Errorf("failed to get existing rules for family %s: %w", family, err) | 		slog.Debug("wg device already removed, route cleanup might be incomplete", "interface", interfaceId) | ||||||
|  | 		wgDev = nil | ||||||
|  | 	} | ||||||
|  | 	link, err := c.nl.LinkByName(string(interfaceId)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		slog.Debug("physical link already removed, route cleanup might be incomplete", "interface", interfaceId) | ||||||
|  | 		link = nil | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	ruleExists := false | 	fwMark := info.FwMark | ||||||
| 	for _, existingRule := range existingRules { | 	if wgDev != nil && info.FwMark == 0 { | ||||||
| 		if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 { | 		fwMark = uint32(wgDev.FirewallMark) | ||||||
| 			ruleExists = true | 	} | ||||||
| 			break | 	table := info.Table | ||||||
|  | 	if wgDev != nil && info.Table == 0 { | ||||||
|  | 		table = wgDev.FirewallMark // use the fwMark as table, this is the default behavior
 | ||||||
|  | 	} | ||||||
|  | 	linkIndex := -1 | ||||||
|  | 	if link != nil { | ||||||
|  | 		linkIndex = link.Attrs().Index | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps) | ||||||
|  | 	realTable, realFwMark, err := c.getOrCreateRoutingTableAndFwMark(link, table, fwMark) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to get or create routing table and fwmark for %s: %w", interfaceId, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if linkIndex > 0 { | ||||||
|  | 		err = c.removeRoutesForFamily(interfaceId, link, netlink.FAMILY_V4, realTable, realFwMark, cidrsV4) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("failed to remove v4 routes: %w", err) | ||||||
|  | 		} | ||||||
|  | 		err = c.removeRoutesForFamily(interfaceId, link, netlink.FAMILY_V6, realTable, realFwMark, cidrsV6) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("failed to remove v6 routes: %w", err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if ruleExists { | 	if table > 0 { | ||||||
| 		return nil // rule already exists, skip re-creation
 | 		err = c.removeRouteRulesForTable(netlink.FAMILY_V4, realTable) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("failed to remove v4 route rules for %s: %w", interfaceId, err) | ||||||
|  | 		} | ||||||
|  | 		err = c.removeRouteRulesForTable(netlink.FAMILY_V6, realTable) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("failed to remove v6 route rules for %s: %w", interfaceId, err) | ||||||
| 		} | 		} | ||||||
| 
 |  | ||||||
| 	if err := c.nl.RuleAdd(&netlink.Rule{ |  | ||||||
| 		Family:            int(family), |  | ||||||
| 		Table:             unix.RT_TABLE_MAIN, |  | ||||||
| 		SuppressIfgroup:   -1, |  | ||||||
| 		SuppressPrefixlen: 0, |  | ||||||
| 		Priority:          c.getMainRulePriority(existingRules), |  | ||||||
| 		Mark:              0, |  | ||||||
| 		Mask:              nil, |  | ||||||
| 		Goto:              -1, |  | ||||||
| 		Flow:              -1, |  | ||||||
| 	}); err != nil { |  | ||||||
| 		return fmt.Errorf("failed to setup rule for main table: %w", err) |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c LocalController) getMainRulePriority(existingRules []netlink.Rule) int { | func (c LocalController) removeRoutesForFamily( | ||||||
| 	priority := c.cfg.Advanced.RulePrioOffset | 	interfaceId domain.InterfaceIdentifier, | ||||||
| 	for { | 	link netlink.Link, | ||||||
| 		isFresh := true | 	family int, | ||||||
| 		for _, existingRule := range existingRules { | 	table int, | ||||||
| 			if existingRule.Priority == priority { | 	fwMark uint32, | ||||||
| 				isFresh = false | 	cidrs []domain.Cidr, | ||||||
| 				break | ) error { | ||||||
| 			} | 	// first remove all rules
 | ||||||
| 		} | 	existingRules, err := c.nl.RuleList(family) | ||||||
| 		if isFresh { |  | ||||||
| 			break |  | ||||||
| 		} else { |  | ||||||
| 			priority++ |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return priority |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (c LocalController) cleanupMainRule(rules []domain.RouteRule) error { |  | ||||||
| 	var family domain.IpFamily |  | ||||||
| 	for _, rule := range rules { |  | ||||||
| 		family = rule.IpFamily |  | ||||||
| 		break |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	existingRules, err := c.nl.RuleList(int(family)) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fmt.Errorf("failed to get existing rules for family %s: %w", family, err) | 		return fmt.Errorf("failed to get existing rules for family %d: %w", family, err) | ||||||
| 	} | 	} | ||||||
| 
 |  | ||||||
| 	shouldHaveMainRule := false |  | ||||||
| 	for _, rule := range rules { |  | ||||||
| 		if rule.HasDefault == true { |  | ||||||
| 			shouldHaveMainRule = true |  | ||||||
| 			break |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	mainRules := 0 |  | ||||||
| 	for _, existingRule := range existingRules { | 	for _, existingRule := range existingRules { | ||||||
| 		if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 { | 		if fwMark == existingRule.Mark && table == existingRule.Table { | ||||||
| 			mainRules++ | 			existingRule.Family = family // set family, somehow the RuleList method does not populate the family field
 | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	removalCount := 0 |  | ||||||
| 	if mainRules > 1 { |  | ||||||
| 		removalCount = mainRules - 1 // we only want one single rule
 |  | ||||||
| 	} |  | ||||||
| 	if !shouldHaveMainRule { |  | ||||||
| 		removalCount = mainRules |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	for _, existingRule := range existingRules { |  | ||||||
| 		if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 { |  | ||||||
| 			if removalCount > 0 { |  | ||||||
| 				existingRule.Family = int(family) // set family, somehow the RuleList method does not populate the family field
 |  | ||||||
| 			if err := c.nl.RuleDel(&existingRule); err != nil { | 			if err := c.nl.RuleDel(&existingRule); err != nil { | ||||||
| 					return fmt.Errorf("failed to delete main rule: %w", err) | 				return fmt.Errorf("failed to delete old fwmark rule: %w", err) | ||||||
| 			} | 			} | ||||||
| 				removalCount-- |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	// next remove all routes
 | ||||||
|  | 	rawRoutes, err := c.nl.RouteListFiltered(family, &netlink.Route{ | ||||||
|  | 		LinkIndex: link.Attrs().Index, | ||||||
|  | 		Table:     unix.RT_TABLE_UNSPEC, // all tables
 | ||||||
|  | 		Scope:     unix.RT_SCOPE_LINK, | ||||||
|  | 		Type:      unix.RTN_UNICAST, | ||||||
|  | 	}, netlink.RT_FILTER_TABLE|netlink.RT_FILTER_TYPE|netlink.RT_FILTER_OIF) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to fetch raw routes for interface %s and family-id %d: %w", | ||||||
|  | 			interfaceId, family, err) | ||||||
|  | 	} | ||||||
|  | 	for _, rawRoute := range rawRoutes { | ||||||
|  | 		if rawRoute.Dst == nil { // handle default route
 | ||||||
|  | 			var netlinkAddr domain.Cidr | ||||||
|  | 			if family == netlink.FAMILY_V4 { | ||||||
|  | 				netlinkAddr, _ = domain.CidrFromString("0.0.0.0/0") | ||||||
|  | 			} else { | ||||||
|  | 				netlinkAddr, _ = domain.CidrFromString("::/0") | ||||||
|  | 			} | ||||||
|  | 			rawRoute.Dst = netlinkAddr.IpNet() | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if rawRoute.Table != table { | ||||||
|  | 			continue // ignore routes from other tables
 | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		route := domain.CidrFromIpNet(*rawRoute.Dst) | ||||||
|  | 		if !slices.Contains(cidrs, route) { | ||||||
|  | 			continue // only remove routes that were previously added
 | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if err := c.nl.RouteDel(&rawRoute); err != nil { | ||||||
|  | 			return fmt.Errorf("failed to remove old route %s from interface %s: %w", route, interfaceId, err) | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c LocalController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error { | func (c LocalController) removeRouteRulesForTable( | ||||||
| 	// TODO implement me
 | 	family int, | ||||||
| 	panic("implement me") | 	table int, | ||||||
|  | ) error { | ||||||
|  | 	existingRules, err := c.nl.RuleList(family) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to get existing route rules for family-id %d: %w", family, err) | ||||||
|  | 	} | ||||||
|  | 	for _, existingRule := range existingRules { | ||||||
|  | 		if existingRule.Table == table { | ||||||
|  | 			err := c.nl.RuleDel(&existingRule) | ||||||
|  | 			if err != nil { | ||||||
|  | 				return fmt.Errorf("failed to delete old rule for table %d and family-id %d: %w", table, family, err) | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // endregion routing-related
 | // endregion routing-related
 | ||||||
|  |  | ||||||
|  | @ -15,6 +15,9 @@ import ( | ||||||
| 	"github.com/h44z/wg-portal/internal/lowlevel" | 	"github.com/h44z/wg-portal/internal/lowlevel" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | const MikrotikRouteDistance = 5 | ||||||
|  | const MikrotikDefaultRoutingTable = "main" | ||||||
|  | 
 | ||||||
| type MikrotikController struct { | type MikrotikController struct { | ||||||
| 	coreCfg *config.Config | 	coreCfg *config.Config | ||||||
| 	cfg     *config.BackendMikrotik | 	cfg     *config.BackendMikrotik | ||||||
|  | @ -24,6 +27,7 @@ type MikrotikController struct { | ||||||
| 	// Add mutexes to prevent race conditions
 | 	// Add mutexes to prevent race conditions
 | ||||||
| 	interfaceMutexes sync.Map   // map[domain.InterfaceIdentifier]*sync.Mutex
 | 	interfaceMutexes sync.Map   // map[domain.InterfaceIdentifier]*sync.Mutex
 | ||||||
| 	peerMutexes      sync.Map   // map[domain.PeerIdentifier]*sync.Mutex
 | 	peerMutexes      sync.Map   // map[domain.PeerIdentifier]*sync.Mutex
 | ||||||
|  | 	coreMutex        sync.Mutex // for updating the core configuration such as routing table or DNS settings
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikController, error) { | func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikController, error) { | ||||||
|  | @ -40,6 +44,7 @@ func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik) | ||||||
| 
 | 
 | ||||||
| 		interfaceMutexes: sync.Map{}, | 		interfaceMutexes: sync.Map{}, | ||||||
| 		peerMutexes:      sync.Map{}, | 		peerMutexes:      sync.Map{}, | ||||||
|  | 		coreMutex:        sync.Mutex{}, | ||||||
| 	}, nil | 	}, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -763,33 +768,404 @@ func (c *MikrotikController) DeletePeer( | ||||||
| 
 | 
 | ||||||
| // region wg-quick-related
 | // region wg-quick-related
 | ||||||
| 
 | 
 | ||||||
| func (c *MikrotikController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error { | func (c *MikrotikController) ExecuteInterfaceHook( | ||||||
|  | 	_ context.Context, | ||||||
|  | 	_ domain.InterfaceIdentifier, | ||||||
|  | 	_ string, | ||||||
|  | ) error { | ||||||
| 	// TODO implement me
 | 	// TODO implement me
 | ||||||
| 	panic("implement me") | 	slog.Error("interface hooks are not yet supported for Mikrotik backends, please open an issue on GitHub") | ||||||
|  | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c *MikrotikController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error { | func (c *MikrotikController) SetDNS( | ||||||
| 	// TODO implement me
 | 	ctx context.Context, | ||||||
| 	panic("implement me") | 	_ domain.InterfaceIdentifier, | ||||||
|  | 	dnsStr, _ string, | ||||||
|  | ) error { | ||||||
|  | 	// Lock the interface to prevent concurrent modifications
 | ||||||
|  | 	c.coreMutex.Lock() | ||||||
|  | 	defer c.coreMutex.Unlock() | ||||||
|  | 
 | ||||||
|  | 	// check if the server is already configured
 | ||||||
|  | 	wgReply := c.client.Get(ctx, "/ip/dns", &lowlevel.MikrotikRequestOptions{ | ||||||
|  | 		PropList: []string{"servers"}, | ||||||
|  | 	}) | ||||||
|  | 	if wgReply.Status != lowlevel.MikrotikApiStatusOk { | ||||||
|  | 		return fmt.Errorf("unable to find WireGuard dns settings: %v", wgReply.Error) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	var existingServers []string | ||||||
|  | 	existingServers = append(existingServers, strings.Split(wgReply.Data.GetString("servers"), ",")...) | ||||||
|  | 
 | ||||||
|  | 	newServers := strings.Split(dnsStr, ",") | ||||||
|  | 
 | ||||||
|  | 	mergedServers := slices.Clone(existingServers) | ||||||
|  | 	for _, s := range newServers { | ||||||
|  | 		if s == "" { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 		if !slices.Contains(mergedServers, s) { | ||||||
|  | 			mergedServers = append(mergedServers, s) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	mergedServersStr := strings.Join(mergedServers, ",") | ||||||
|  | 
 | ||||||
|  | 	reply := c.client.ExecList(ctx, "/ip/dns/set", lowlevel.GenericJsonObject{ | ||||||
|  | 		"servers": mergedServersStr, | ||||||
|  | 	}) | ||||||
|  | 	if reply.Status != lowlevel.MikrotikApiStatusOk { | ||||||
|  | 		return fmt.Errorf("failed to set DNS servers: %s: %v", mergedServersStr, reply.Error) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c *MikrotikController) UnsetDNS(id domain.InterfaceIdentifier) error { | func (c *MikrotikController) UnsetDNS( | ||||||
| 	// TODO implement me
 | 	ctx context.Context, | ||||||
| 	panic("implement me") | 	_ domain.InterfaceIdentifier, | ||||||
|  | 	dnsStr, _ string, | ||||||
|  | ) error { | ||||||
|  | 	// Lock the interface to prevent concurrent modifications
 | ||||||
|  | 	c.coreMutex.Lock() | ||||||
|  | 	defer c.coreMutex.Unlock() | ||||||
|  | 
 | ||||||
|  | 	// retrieve current DNS settings
 | ||||||
|  | 	wgReply := c.client.Get(ctx, "/ip/dns", &lowlevel.MikrotikRequestOptions{ | ||||||
|  | 		PropList: []string{"servers"}, | ||||||
|  | 	}) | ||||||
|  | 	if wgReply.Status != lowlevel.MikrotikApiStatusOk { | ||||||
|  | 		return fmt.Errorf("unable to find WireGuard dns settings: %v", wgReply.Error) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	var existingServers []string | ||||||
|  | 	existingServers = append(existingServers, strings.Split(wgReply.Data.GetString("servers"), ",")...) | ||||||
|  | 
 | ||||||
|  | 	oldServers := strings.Split(dnsStr, ",") | ||||||
|  | 
 | ||||||
|  | 	mergedServers := make([]string, 0, len(existingServers)) | ||||||
|  | 	for _, s := range existingServers { | ||||||
|  | 		if s == "" { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 		if !slices.Contains(oldServers, s) { | ||||||
|  | 			mergedServers = append(mergedServers, s) // only keep the servers that are not in the old list
 | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	mergedServersStr := strings.Join(mergedServers, ",") | ||||||
|  | 
 | ||||||
|  | 	reply := c.client.ExecList(ctx, "/ip/dns/set", lowlevel.GenericJsonObject{ | ||||||
|  | 		"servers": mergedServersStr, | ||||||
|  | 	}) | ||||||
|  | 	if reply.Status != lowlevel.MikrotikApiStatusOk { | ||||||
|  | 		return fmt.Errorf("failed to set DNS servers: %s: %v", mergedServersStr, reply.Error) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // endregion wg-quick-related
 | // endregion wg-quick-related
 | ||||||
| 
 | 
 | ||||||
| // region routing-related
 | // region routing-related
 | ||||||
| 
 | 
 | ||||||
| func (c *MikrotikController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error { | // SetRoutes sets the routes for the given interface. If no routes are provided, the function is a no-op.
 | ||||||
| 	// TODO implement me
 | func (c *MikrotikController) SetRoutes(ctx context.Context, info domain.RoutingTableInfo) error { | ||||||
| 	panic("implement me") | 	interfaceId := info.Interface.Identifier | ||||||
|  | 	slog.Debug("setting mikrotik routes", "interface", interfaceId, "table", info.TableStr, "cidrs", info.AllowedIps) | ||||||
|  | 
 | ||||||
|  | 	// Mikrotik needs some time to apply the changes.
 | ||||||
|  | 	// If we don't wait, the routes might get created multiple times as the dynamic routes are not yet available.
 | ||||||
|  | 	time.Sleep(2 * time.Second) | ||||||
|  | 
 | ||||||
|  | 	tableName, err := c.getOrCreateRoutingTables(ctx, info.Interface.Identifier, info.TableStr) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to get or create routing table for %s: %v", interfaceId, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps) | ||||||
|  | 
 | ||||||
|  | 	err = c.setRoutesForFamily(ctx, interfaceId, false, tableName, cidrsV4) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to set IPv4 routes for %s: %v", interfaceId, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	err = c.setRoutesForFamily(ctx, interfaceId, true, tableName, cidrsV6) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to set IPv6 routes for %s: %v", interfaceId, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c *MikrotikController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error { | func (c *MikrotikController) resolveRouteTableName(name string) string { | ||||||
| 	// TODO implement me
 | 	name = strings.TrimSpace(name) | ||||||
| 	panic("implement me") | 
 | ||||||
|  | 	var mikrotikTableName string | ||||||
|  | 	switch strings.ToLower(name) { | ||||||
|  | 	case "", "0": | ||||||
|  | 		mikrotikTableName = MikrotikDefaultRoutingTable | ||||||
|  | 	case MikrotikDefaultRoutingTable: | ||||||
|  | 		return fmt.Sprintf("wgportal-%s", | ||||||
|  | 			MikrotikDefaultRoutingTable) // if the Mikrotik Main table should be used, the table-name should be left empty or set to "0".
 | ||||||
|  | 	default: | ||||||
|  | 		mikrotikTableName = name | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return mikrotikTableName | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (c *MikrotikController) getOrCreateRoutingTables( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	interfaceId domain.InterfaceIdentifier, | ||||||
|  | 	table string, | ||||||
|  | ) (string, error) { | ||||||
|  | 	// retrieve current routing tables
 | ||||||
|  | 	wgReply := c.client.Query(ctx, "/routing/table", &lowlevel.MikrotikRequestOptions{ | ||||||
|  | 		PropList: []string{ | ||||||
|  | 			".id", "dynamic", "fib", "name", | ||||||
|  | 		}, | ||||||
|  | 	}) | ||||||
|  | 	if wgReply.Status != lowlevel.MikrotikApiStatusOk { | ||||||
|  | 		return "", fmt.Errorf("unable to query routing tables: %v", wgReply.Error) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	wantedTableName := c.resolveRouteTableName(table) | ||||||
|  | 
 | ||||||
|  | 	// check if the table already exists
 | ||||||
|  | 	for _, table := range wgReply.Data { | ||||||
|  | 		if table.GetString("name") == wantedTableName { | ||||||
|  | 			return wantedTableName, nil // already exists, nothing to do
 | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// create the table if it does not exist
 | ||||||
|  | 	createReply := c.client.Create(ctx, "/routing/table", lowlevel.GenericJsonObject{ | ||||||
|  | 		"name":    wantedTableName, | ||||||
|  | 		"comment": fmt.Sprintf("Routing Table for %s", interfaceId), | ||||||
|  | 		"fib":     strconv.FormatBool(true), | ||||||
|  | 	}) | ||||||
|  | 	if createReply.Status != lowlevel.MikrotikApiStatusOk { | ||||||
|  | 		return "", fmt.Errorf("failed to create routing table %s: %v", wantedTableName, createReply.Error) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return wantedTableName, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (c *MikrotikController) setRoutesForFamily( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	interfaceId domain.InterfaceIdentifier, | ||||||
|  | 	ipV6 bool, | ||||||
|  | 	table string, | ||||||
|  | 	cidrs []domain.Cidr, | ||||||
|  | ) error { | ||||||
|  | 	apiPath := "/ip/route" | ||||||
|  | 	if ipV6 { | ||||||
|  | 		apiPath = "/ipv6/route" | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// retrieve current routes
 | ||||||
|  | 	wgReply := c.client.Query(ctx, apiPath, &lowlevel.MikrotikRequestOptions{ | ||||||
|  | 		PropList: []string{ | ||||||
|  | 			".id", "disabled", "inactive", "distance", "dst-address", "dynamic", "gateway", "immediate-gw", | ||||||
|  | 			"routing-table", "scope", "target-scope", "client-dns", "comment", "disabled", "responder", | ||||||
|  | 		}, | ||||||
|  | 		Filters: map[string]string{ | ||||||
|  | 			"gateway": string(interfaceId), | ||||||
|  | 		}, | ||||||
|  | 	}) | ||||||
|  | 	if wgReply.Status != lowlevel.MikrotikApiStatusOk { | ||||||
|  | 		return fmt.Errorf("unable to find WireGuard IP route settings (v6=%t): %v", ipV6, wgReply.Error) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// first create or update the routes
 | ||||||
|  | 	for _, cidr := range cidrs { | ||||||
|  | 		// check if the route already exists
 | ||||||
|  | 		exists := false | ||||||
|  | 		for _, route := range wgReply.Data { | ||||||
|  | 			existingRoute, err := domain.CidrFromString(route.GetString("dst-address")) | ||||||
|  | 			if err != nil { | ||||||
|  | 				slog.Warn("failed to parse route destination address", | ||||||
|  | 					"cidr", route.GetString("dst-address"), "error", err) | ||||||
|  | 				continue | ||||||
|  | 			} | ||||||
|  | 			if existingRoute.EqualPrefix(cidr) && route.GetString("routing-table") == table { | ||||||
|  | 				exists = true | ||||||
|  | 				break | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		if exists { | ||||||
|  | 			continue // route already exists, nothing to do
 | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// create the route
 | ||||||
|  | 		reply := c.client.Create(ctx, apiPath, lowlevel.GenericJsonObject{ | ||||||
|  | 			"gateway":       string(interfaceId), | ||||||
|  | 			"dst-address":   cidr.String(), | ||||||
|  | 			"distance":      strconv.Itoa(MikrotikRouteDistance), | ||||||
|  | 			"disabled":      strconv.FormatBool(false), | ||||||
|  | 			"routing-table": table, | ||||||
|  | 		}) | ||||||
|  | 		if reply.Status != lowlevel.MikrotikApiStatusOk { | ||||||
|  | 			return fmt.Errorf("failed to create new route %s via %s: %v", cidr.String(), interfaceId, reply.Error) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// finally, remove the routes that are not in the new list
 | ||||||
|  | 	for _, route := range wgReply.Data { | ||||||
|  | 		if route.GetBool("dynamic") { | ||||||
|  | 			continue // dynamic routes are not managed by the controller, nothing to do
 | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		existingRoute, err := domain.CidrFromString(route.GetString("dst-address")) | ||||||
|  | 		if err != nil { | ||||||
|  | 			slog.Warn("failed to parse route destination address", | ||||||
|  | 				"cidr", route.GetString("dst-address"), "error", err) | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		valid := false | ||||||
|  | 		for _, cidr := range cidrs { | ||||||
|  | 			if existingRoute.EqualPrefix(cidr) { | ||||||
|  | 				valid = true | ||||||
|  | 				break | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		if valid { | ||||||
|  | 			continue // route is still valid, nothing to do
 | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// remove the route
 | ||||||
|  | 		reply := c.client.Delete(ctx, apiPath+"/"+route.GetString(".id")) | ||||||
|  | 		if reply.Status != lowlevel.MikrotikApiStatusOk { | ||||||
|  | 			return fmt.Errorf("failed to remove outdated route %s: %v", existingRoute.String(), reply.Error) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // RemoveRoutes removes the routes for the given interface. If no routes are provided, the function is a no-op.
 | ||||||
|  | func (c *MikrotikController) RemoveRoutes(ctx context.Context, info domain.RoutingTableInfo) error { | ||||||
|  | 	interfaceId := info.Interface.Identifier | ||||||
|  | 	slog.Debug("removing mikrotik routes", "interface", interfaceId, "table", info.TableStr, "cidrs", info.AllowedIps) | ||||||
|  | 
 | ||||||
|  | 	tableName := c.resolveRouteTableName(info.TableStr) | ||||||
|  | 
 | ||||||
|  | 	cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps) | ||||||
|  | 
 | ||||||
|  | 	err := c.removeRoutesForFamily(ctx, interfaceId, false, tableName, cidrsV4) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to remove IPv4 routes for %s: %v", interfaceId, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	err = c.removeRoutesForFamily(ctx, interfaceId, true, tableName, cidrsV6) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to remove IPv6 routes for %s: %v", interfaceId, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	err = c.removeRoutingTable(ctx, tableName) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to remove routing table for %s: %v", interfaceId, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (c *MikrotikController) removeRoutesForFamily( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	interfaceId domain.InterfaceIdentifier, | ||||||
|  | 	ipV6 bool, | ||||||
|  | 	table string, | ||||||
|  | 	cidrs []domain.Cidr, | ||||||
|  | ) error { | ||||||
|  | 	apiPath := "/ip/route" | ||||||
|  | 	if ipV6 { | ||||||
|  | 		apiPath = "/ipv6/route" | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// retrieve current routes
 | ||||||
|  | 	wgReply := c.client.Query(ctx, apiPath, &lowlevel.MikrotikRequestOptions{ | ||||||
|  | 		PropList: []string{ | ||||||
|  | 			".id", "disabled", "inactive", "distance", "dst-address", "dynamic", "gateway", "immediate-gw", | ||||||
|  | 			"routing-table", "scope", "target-scope", "client-dns", "comment", "disabled", "responder", | ||||||
|  | 		}, | ||||||
|  | 		Filters: map[string]string{ | ||||||
|  | 			"gateway": string(interfaceId), | ||||||
|  | 		}, | ||||||
|  | 	}) | ||||||
|  | 	if wgReply.Status != lowlevel.MikrotikApiStatusOk { | ||||||
|  | 		return fmt.Errorf("unable to find WireGuard IP route settings (v6=%t): %v", ipV6, wgReply.Error) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// remove the routes from the list
 | ||||||
|  | 	for _, route := range wgReply.Data { | ||||||
|  | 		if route.GetBool("dynamic") { | ||||||
|  | 			continue // dynamic routes are not managed by the controller, nothing to do
 | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		existingRoute, err := domain.CidrFromString(route.GetString("dst-address")) | ||||||
|  | 		if err != nil { | ||||||
|  | 			slog.Warn("failed to parse route destination address", | ||||||
|  | 				"cidr", route.GetString("dst-address"), "error", err) | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		remove := false | ||||||
|  | 		for _, cidr := range cidrs { | ||||||
|  | 			if existingRoute.EqualPrefix(cidr) && route.GetString("routing-table") == table { | ||||||
|  | 				remove = true | ||||||
|  | 				break | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		if !remove { | ||||||
|  | 			continue // route is still valid, nothing to do
 | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// remove the route
 | ||||||
|  | 		reply := c.client.Delete(ctx, apiPath+"/"+route.GetString(".id")) | ||||||
|  | 		if reply.Status != lowlevel.MikrotikApiStatusOk { | ||||||
|  | 			return fmt.Errorf("failed to remove old route %s: %v", existingRoute.String(), reply.Error) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (c *MikrotikController) removeRoutingTable( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	table string, | ||||||
|  | ) error { | ||||||
|  | 	if table == MikrotikDefaultRoutingTable { | ||||||
|  | 		return nil // we cannot remove the default table
 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// retrieve current routing tables
 | ||||||
|  | 	wgReply := c.client.Query(ctx, "/routing/table", &lowlevel.MikrotikRequestOptions{ | ||||||
|  | 		PropList: []string{ | ||||||
|  | 			".id", "dynamic", "fib", "name", | ||||||
|  | 		}, | ||||||
|  | 	}) | ||||||
|  | 	if wgReply.Status != lowlevel.MikrotikApiStatusOk { | ||||||
|  | 		return fmt.Errorf("unable to query routing tables: %v", wgReply.Error) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	for _, existingTable := range wgReply.Data { | ||||||
|  | 		if existingTable.GetBool("dynamic") { | ||||||
|  | 			continue // dynamic tables are not managed by the controller, nothing to do
 | ||||||
|  | 		} | ||||||
|  | 		if existingTable.GetString("name") != table { | ||||||
|  | 			continue // not the table we want to remove
 | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// remove the table
 | ||||||
|  | 		reply := c.client.Delete(ctx, "/routing/table/"+existingTable.GetString(".id")) | ||||||
|  | 		if reply.Status != lowlevel.MikrotikApiStatusOk { | ||||||
|  | 			return fmt.Errorf("failed to remove routing table %s: %v", table, reply.Error) | ||||||
|  | 		} | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // endregion routing-related
 | // endregion routing-related
 | ||||||
|  |  | ||||||
|  | @ -1,113 +0,0 @@ | ||||||
| package adapters |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"bytes" |  | ||||||
| 	"fmt" |  | ||||||
| 	"log/slog" |  | ||||||
| 	"os/exec" |  | ||||||
| 	"strings" |  | ||||||
| 
 |  | ||||||
| 	"github.com/h44z/wg-portal/internal" |  | ||||||
| 	"github.com/h44z/wg-portal/internal/domain" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| // WgQuickRepo implements higher level wg-quick like interactions like setting DNS, routing tables or interface hooks.
 |  | ||||||
| type WgQuickRepo struct { |  | ||||||
| 	shellCmd              string |  | ||||||
| 	resolvConfIfacePrefix string |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // NewWgQuickRepo creates a new WgQuickRepo instance.
 |  | ||||||
| func NewWgQuickRepo() *WgQuickRepo { |  | ||||||
| 	return &WgQuickRepo{ |  | ||||||
| 		shellCmd:              "bash", |  | ||||||
| 		resolvConfIfacePrefix: "tun.", |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // ExecuteInterfaceHook executes the given hook command.
 |  | ||||||
| // The hook command can contain the following placeholders:
 |  | ||||||
| //
 |  | ||||||
| //	%i: the interface identifier.
 |  | ||||||
| func (r *WgQuickRepo) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error { |  | ||||||
| 	if hookCmd == "" { |  | ||||||
| 		return nil |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	slog.Debug("executing interface hook", "interface", id, "hook", hookCmd) |  | ||||||
| 	err := r.exec(hookCmd, id) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return fmt.Errorf("failed to exec hook: %w", err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // SetDNS sets the DNS settings for the given interface. It uses resolvconf to set the DNS settings.
 |  | ||||||
| func (r *WgQuickRepo) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error { |  | ||||||
| 	if dnsStr == "" && dnsSearchStr == "" { |  | ||||||
| 		return nil |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	dnsServers := internal.SliceString(dnsStr) |  | ||||||
| 	dnsSearchDomains := internal.SliceString(dnsSearchStr) |  | ||||||
| 
 |  | ||||||
| 	dnsCommand := "resolvconf -a %resPref%i -m 0 -x" |  | ||||||
| 	dnsCommandInput := make([]string, 0, len(dnsServers)+len(dnsSearchDomains)) |  | ||||||
| 
 |  | ||||||
| 	for _, dnsServer := range dnsServers { |  | ||||||
| 		dnsCommandInput = append(dnsCommandInput, fmt.Sprintf("nameserver %s", dnsServer)) |  | ||||||
| 	} |  | ||||||
| 	for _, searchDomain := range dnsSearchDomains { |  | ||||||
| 		dnsCommandInput = append(dnsCommandInput, fmt.Sprintf("search %s", searchDomain)) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	err := r.exec(dnsCommand, id, dnsCommandInput...) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return fmt.Errorf( |  | ||||||
| 			"failed to set dns settings (is resolvconf available?, for systemd create this symlink: ln -s /usr/bin/resolvectl /usr/local/bin/resolvconf): %w", |  | ||||||
| 			err, |  | ||||||
| 		) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // UnsetDNS unsets the DNS settings for the given interface. It uses resolvconf to unset the DNS settings.
 |  | ||||||
| func (r *WgQuickRepo) UnsetDNS(id domain.InterfaceIdentifier) error { |  | ||||||
| 	dnsCommand := "resolvconf -d %resPref%i -f" |  | ||||||
| 
 |  | ||||||
| 	err := r.exec(dnsCommand, id) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return fmt.Errorf("failed to unset dns settings: %w", err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (r *WgQuickRepo) replaceCommandPlaceHolders(command string, interfaceId domain.InterfaceIdentifier) string { |  | ||||||
| 	command = strings.ReplaceAll(command, "%resPref", r.resolvConfIfacePrefix) |  | ||||||
| 	return strings.ReplaceAll(command, "%i", string(interfaceId)) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (r *WgQuickRepo) exec(command string, interfaceId domain.InterfaceIdentifier, stdin ...string) error { |  | ||||||
| 	commandWithInterfaceName := r.replaceCommandPlaceHolders(command, interfaceId) |  | ||||||
| 	cmd := exec.Command(r.shellCmd, "-ce", commandWithInterfaceName) |  | ||||||
| 	if len(stdin) > 0 { |  | ||||||
| 		b := &bytes.Buffer{} |  | ||||||
| 		for _, ln := range stdin { |  | ||||||
| 			if _, err := fmt.Fprint(b, ln); err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		cmd.Stdin = b |  | ||||||
| 	} |  | ||||||
| 	out, err := cmd.CombinedOutput() // execute and wait for output
 |  | ||||||
| 	if err != nil { |  | ||||||
| 		return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err) |  | ||||||
| 	} |  | ||||||
| 	slog.Debug("executed shell command", |  | ||||||
| 		"command", commandWithInterfaceName, |  | ||||||
| 		"output", string(out)) |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
|  | @ -1550,6 +1550,38 @@ | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|  |         "/user/{id}/change-password": { | ||||||
|  |             "post": { | ||||||
|  |                 "produces": [ | ||||||
|  |                     "application/json" | ||||||
|  |                 ], | ||||||
|  |                 "tags": [ | ||||||
|  |                     "Users" | ||||||
|  |                 ], | ||||||
|  |                 "summary": "Change the password for the given user.", | ||||||
|  |                 "operationId": "users_handleChangePasswordPost", | ||||||
|  |                 "responses": { | ||||||
|  |                     "200": { | ||||||
|  |                         "description": "OK", | ||||||
|  |                         "schema": { | ||||||
|  |                             "$ref": "#/definitions/model.User" | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     "400": { | ||||||
|  |                         "description": "Bad Request", | ||||||
|  |                         "schema": { | ||||||
|  |                             "$ref": "#/definitions/model.Error" | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     "500": { | ||||||
|  |                         "description": "Internal Server Error", | ||||||
|  |                         "schema": { | ||||||
|  |                             "$ref": "#/definitions/model.Error" | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|         "/user/{id}/interfaces": { |         "/user/{id}/interfaces": { | ||||||
|             "get": { |             "get": { | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|  | @ -2159,6 +2191,10 @@ | ||||||
|                         } |                         } | ||||||
|                     ] |                     ] | ||||||
|                 }, |                 }, | ||||||
|  |                 "UserDisplayName": { | ||||||
|  |                     "description": "the owner display name", | ||||||
|  |                     "type": "string" | ||||||
|  |                 }, | ||||||
|                 "UserIdentifier": { |                 "UserIdentifier": { | ||||||
|                     "description": "the owner", |                     "description": "the owner", | ||||||
|                     "type": "string" |                     "type": "string" | ||||||
|  |  | ||||||
|  | @ -322,6 +322,9 @@ definitions: | ||||||
|         allOf: |         allOf: | ||||||
|         - $ref: '#/definitions/model.ConfigOption-string' |         - $ref: '#/definitions/model.ConfigOption-string' | ||||||
|         description: the routing table |         description: the routing table | ||||||
|  |       UserDisplayName: | ||||||
|  |         description: the owner display name | ||||||
|  |         type: string | ||||||
|       UserIdentifier: |       UserIdentifier: | ||||||
|         description: the owner |         description: the owner | ||||||
|         type: string |         type: string | ||||||
|  | @ -1442,6 +1445,27 @@ paths: | ||||||
|       summary: Enable the REST API for the given user. |       summary: Enable the REST API for the given user. | ||||||
|       tags: |       tags: | ||||||
|       - Users |       - Users | ||||||
|  |   /user/{id}/change-password: | ||||||
|  |     post: | ||||||
|  |       operationId: users_handleChangePasswordPost | ||||||
|  |       produces: | ||||||
|  |       - application/json | ||||||
|  |       responses: | ||||||
|  |         "200": | ||||||
|  |           description: OK | ||||||
|  |           schema: | ||||||
|  |             $ref: '#/definitions/model.User' | ||||||
|  |         "400": | ||||||
|  |           description: Bad Request | ||||||
|  |           schema: | ||||||
|  |             $ref: '#/definitions/model.Error' | ||||||
|  |         "500": | ||||||
|  |           description: Internal Server Error | ||||||
|  |           schema: | ||||||
|  |             $ref: '#/definitions/model.Error' | ||||||
|  |       summary: Change the password for the given user. | ||||||
|  |       tags: | ||||||
|  |       - Users | ||||||
|   /user/{id}/interfaces: |   /user/{id}/interfaces: | ||||||
|     get: |     get: | ||||||
|       operationId: users_handleInterfacesGet |       operationId: users_handleInterfacesGet | ||||||
|  |  | ||||||
|  | @ -17,11 +17,6 @@ | ||||||
|     "paths": { |     "paths": { | ||||||
|         "/interface/all": { |         "/interface/all": { | ||||||
|             "get": { |             "get": { | ||||||
|                 "security": [ |  | ||||||
|                     { |  | ||||||
|                         "BasicAuth": [] |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|                 ], |                 ], | ||||||
|  | @ -52,16 +47,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/interface/by-id/{id}": { |  | ||||||
|             "get": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/interface/by-id/{id}": { | ||||||
|  |             "get": { | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|                 ], |                 ], | ||||||
|  | @ -110,14 +105,14 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|                 }, |                 }, | ||||||
|             "put": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             }, | ||||||
|  |             "put": { | ||||||
|                 "description": "This endpoint updates an existing interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).", |                 "description": "This endpoint updates an existing interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|  | @ -182,14 +177,14 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|                 }, |                 }, | ||||||
|             "delete": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             }, | ||||||
|  |             "delete": { | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|                 ], |                 ], | ||||||
|  | @ -241,16 +236,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/interface/new": { |  | ||||||
|             "post": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/interface/new": { | ||||||
|  |             "post": { | ||||||
|                 "description": "This endpoint creates a new interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).", |                 "description": "This endpoint creates a new interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|  | @ -308,16 +303,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/interface/prepare": { |  | ||||||
|             "get": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/interface/prepare": { | ||||||
|  |             "get": { | ||||||
|                 "description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).", |                 "description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|  | @ -352,16 +347,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/metrics/by-interface/{id}": { |  | ||||||
|             "get": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/metrics/by-interface/{id}": { | ||||||
|  |             "get": { | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|                 ], |                 ], | ||||||
|  | @ -410,16 +405,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/metrics/by-peer/{id}": { |  | ||||||
|             "get": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/metrics/by-peer/{id}": { | ||||||
|  |             "get": { | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|                 ], |                 ], | ||||||
|  | @ -468,16 +463,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/metrics/by-user/{id}": { |  | ||||||
|             "get": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/metrics/by-user/{id}": { | ||||||
|  |             "get": { | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|                 ], |                 ], | ||||||
|  | @ -526,16 +521,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/peer/by-id/{id}": { |  | ||||||
|             "get": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/peer/by-id/{id}": { | ||||||
|  |             "get": { | ||||||
|                 "description": "Normal users can only access their own records. Admins can access all records.", |                 "description": "Normal users can only access their own records. Admins can access all records.", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|  | @ -585,14 +580,14 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|                 }, |                 }, | ||||||
|             "put": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             }, | ||||||
|  |             "put": { | ||||||
|                 "description": "Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).", |                 "description": "Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|  | @ -657,14 +652,14 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|                 }, |                 }, | ||||||
|             "delete": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             }, | ||||||
|  |             "delete": { | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|                 ], |                 ], | ||||||
|  | @ -716,16 +711,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/peer/by-interface/{id}": { |  | ||||||
|             "get": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/peer/by-interface/{id}": { | ||||||
|  |             "get": { | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|                 ], |                 ], | ||||||
|  | @ -765,16 +760,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/peer/by-user/{id}": { |  | ||||||
|             "get": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/peer/by-user/{id}": { | ||||||
|  |             "get": { | ||||||
|                 "description": "Normal users can only access their own records. Admins can access all records.", |                 "description": "Normal users can only access their own records. Admins can access all records.", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|  | @ -815,16 +810,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/peer/new": { |  | ||||||
|             "post": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/peer/new": { | ||||||
|  |             "post": { | ||||||
|                 "description": "Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).", |                 "description": "Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|  | @ -882,16 +877,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/peer/prepare/{id}": { |  | ||||||
|             "get": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/peer/prepare/{id}": { | ||||||
|  |             "get": { | ||||||
|                 "description": "This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.", |                 "description": "This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|  | @ -947,16 +942,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/provisioning/data/peer-config": { |  | ||||||
|             "get": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/provisioning/data/peer-config": { | ||||||
|  |             "get": { | ||||||
|                 "description": "Normal users can only access their own record. Admins can access all records.", |                 "description": "Normal users can only access their own record. Admins can access all records.", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "text/plain", |                     "text/plain", | ||||||
|  | @ -1013,16 +1008,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/provisioning/data/peer-qr": { |  | ||||||
|             "get": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/provisioning/data/peer-qr": { | ||||||
|  |             "get": { | ||||||
|                 "description": "Normal users can only access their own record. Admins can access all records.", |                 "description": "Normal users can only access their own record. Admins can access all records.", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "image/png", |                     "image/png", | ||||||
|  | @ -1079,16 +1074,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/provisioning/data/user-info": { |  | ||||||
|             "get": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/provisioning/data/user-info": { | ||||||
|  |             "get": { | ||||||
|                 "description": "Normal users can only access their own record. Admins can access all records.", |                 "description": "Normal users can only access their own record. Admins can access all records.", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|  | @ -1149,16 +1144,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/provisioning/new-peer": { |  | ||||||
|             "post": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/provisioning/new-peer": { | ||||||
|  |             "post": { | ||||||
|                 "description": "Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.", |                 "description": "Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|  | @ -1216,16 +1211,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/user/all": { |  | ||||||
|             "get": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/user/all": { | ||||||
|  |             "get": { | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|                 ], |                 ], | ||||||
|  | @ -1256,16 +1251,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/user/by-id/{id}": { |  | ||||||
|             "get": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/user/by-id/{id}": { | ||||||
|  |             "get": { | ||||||
|                 "description": "Normal users can only access their own record. Admins can access all records.", |                 "description": "Normal users can only access their own record. Admins can access all records.", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|  | @ -1315,14 +1310,14 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|                 }, |                 }, | ||||||
|             "put": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             }, | ||||||
|  |             "put": { | ||||||
|                 "description": "Only admins can update existing records.", |                 "description": "Only admins can update existing records.", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|  | @ -1387,14 +1382,14 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|                 }, |                 }, | ||||||
|             "delete": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             }, | ||||||
|  |             "delete": { | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|                 ], |                 ], | ||||||
|  | @ -1446,16 +1441,16 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|                 }, |                 }, | ||||||
|         "/user/new": { |  | ||||||
|             "post": { |  | ||||||
|                 "security": [ |                 "security": [ | ||||||
|                     { |                     { | ||||||
|                         "BasicAuth": [] |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|                 ], |                 ] | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "/user/new": { | ||||||
|  |             "post": { | ||||||
|                 "description": "Only admins can create new records.", |                 "description": "Only admins can create new records.", | ||||||
|                 "produces": [ |                 "produces": [ | ||||||
|                     "application/json" |                     "application/json" | ||||||
|  | @ -1513,7 +1508,12 @@ | ||||||
|                             "$ref": "#/definitions/models.Error" |                             "$ref": "#/definitions/models.Error" | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|  |                 }, | ||||||
|  |                 "security": [ | ||||||
|  |                     { | ||||||
|  |                         "BasicAuth": [] | ||||||
|                     } |                     } | ||||||
|  |                 ] | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|  | @ -2,6 +2,8 @@ package backend | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
|  | 	"fmt" | ||||||
|  | 	"strings" | ||||||
| 
 | 
 | ||||||
| 	"github.com/h44z/wg-portal/internal/config" | 	"github.com/h44z/wg-portal/internal/config" | ||||||
| 	"github.com/h44z/wg-portal/internal/domain" | 	"github.com/h44z/wg-portal/internal/domain" | ||||||
|  | @ -70,6 +72,44 @@ func (u UserService) DeactivateApi(ctx context.Context, id domain.UserIdentifier | ||||||
| 	return u.users.DeactivateApi(ctx, id) | 	return u.users.DeactivateApi(ctx, id) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (u UserService) ChangePassword(ctx context.Context, id domain.UserIdentifier, oldPassword, newPassword string) (*domain.User, error) { | ||||||
|  | 	oldPassword = strings.TrimSpace(oldPassword) | ||||||
|  | 	newPassword = strings.TrimSpace(newPassword) | ||||||
|  | 
 | ||||||
|  | 	if newPassword == "" { | ||||||
|  | 		return nil, fmt.Errorf("new password must not be empty") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// ensure that the new password is different from the old one
 | ||||||
|  | 	if oldPassword == newPassword { | ||||||
|  | 		return nil, fmt.Errorf("new password must be different from the old one") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	user, err := u.users.GetUser(ctx, id) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to get user: %w", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// ensure that the user uses the database backend; otherwise we can't change the password
 | ||||||
|  | 	if user.Source != domain.UserSourceDatabase { | ||||||
|  | 		return nil, fmt.Errorf("user source %s does not support password changes", user.Source) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// validate old password
 | ||||||
|  | 	if user.CheckPassword(oldPassword) != nil { | ||||||
|  | 		return nil, fmt.Errorf("current password is invalid") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	user.Password = domain.PrivateString(newPassword) | ||||||
|  | 
 | ||||||
|  | 	// ensure that the new password is strong enough
 | ||||||
|  | 	if err := user.HasWeakPassword(u.cfg.Auth.MinPasswordLength); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return u.users.UpdateUser(ctx, user) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (u UserService) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) { | func (u UserService) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) { | ||||||
| 	return u.wg.GetUserPeers(ctx, id) | 	return u.wg.GetUserPeers(ctx, id) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ type UserService interface { | ||||||
| 	ActivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) | 	ActivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) | ||||||
| 	// DeactivateApi disables the API for the user with the given id.
 | 	// DeactivateApi disables the API for the user with the given id.
 | ||||||
| 	DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) | 	DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) | ||||||
|  | 	// ChangePassword changes the password for the user with the given id.
 | ||||||
|  | 	ChangePassword(ctx context.Context, id domain.UserIdentifier, oldPassword, newPassword string) (*domain.User, error) | ||||||
| 	// GetUserPeers returns all peers for the given user.
 | 	// GetUserPeers returns all peers for the given user.
 | ||||||
| 	GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) | 	GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) | ||||||
| 	// GetUserPeerStats returns all peer stats for the given user.
 | 	// GetUserPeerStats returns all peer stats for the given user.
 | ||||||
|  | @ -75,6 +77,7 @@ func (e UserEndpoint) RegisterRoutes(g *routegroup.Bundle) { | ||||||
| 	apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet()) | 	apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet()) | ||||||
| 	apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost()) | 	apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost()) | ||||||
| 	apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost()) | 	apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost()) | ||||||
|  | 	apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/change-password", e.handleChangePasswordPost()) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // handleAllGet returns a gorm Handler function.
 | // handleAllGet returns a gorm Handler function.
 | ||||||
|  | @ -391,3 +394,68 @@ func (e UserEndpoint) handleApiDisablePost() http.HandlerFunc { | ||||||
| 		respond.JSON(w, http.StatusOK, model.NewUser(user, false)) | 		respond.JSON(w, http.StatusOK, model.NewUser(user, false)) | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // handleChangePasswordPost returns a gorm Handler function.
 | ||||||
|  | //
 | ||||||
|  | // @ID users_handleChangePasswordPost
 | ||||||
|  | // @Tags Users
 | ||||||
|  | // @Summary Change the password for the given user.
 | ||||||
|  | // @Produce json
 | ||||||
|  | // @Success 200 {object} model.User
 | ||||||
|  | // @Failure 400 {object} model.Error
 | ||||||
|  | // @Failure 500 {object} model.Error
 | ||||||
|  | // @Router /user/{id}/change-password [post]
 | ||||||
|  | func (e UserEndpoint) handleChangePasswordPost() http.HandlerFunc { | ||||||
|  | 	return func(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 		userId := Base64UrlDecode(request.Path(r, "id")) | ||||||
|  | 		if userId == "" { | ||||||
|  | 			respond.JSON(w, http.StatusBadRequest, | ||||||
|  | 				model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		var passwordData struct { | ||||||
|  | 			OldPassword    string `json:"OldPassword"` | ||||||
|  | 			Password       string `json:"Password"` | ||||||
|  | 			PasswordRepeat string `json:"PasswordRepeat"` | ||||||
|  | 		} | ||||||
|  | 		if err := request.BodyJson(r, &passwordData); err != nil { | ||||||
|  | 			respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if passwordData.OldPassword == "" { | ||||||
|  | 			respond.JSON(w, http.StatusBadRequest, | ||||||
|  | 				model.Error{Code: http.StatusBadRequest, Message: "old password missing"}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if passwordData.Password == "" { | ||||||
|  | 			respond.JSON(w, http.StatusBadRequest, | ||||||
|  | 				model.Error{Code: http.StatusBadRequest, Message: "new password missing"}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if passwordData.OldPassword == passwordData.Password { | ||||||
|  | 			respond.JSON(w, http.StatusBadRequest, | ||||||
|  | 				model.Error{Code: http.StatusBadRequest, Message: "password did not change"}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if passwordData.Password != passwordData.PasswordRepeat { | ||||||
|  | 			respond.JSON(w, http.StatusBadRequest, | ||||||
|  | 				model.Error{Code: http.StatusBadRequest, Message: "password mismatch"}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		user, err := e.userService.ChangePassword(r.Context(), domain.UserIdentifier(userId), | ||||||
|  | 			passwordData.OldPassword, passwordData.Password) | ||||||
|  | 		if err != nil { | ||||||
|  | 			respond.JSON(w, http.StatusInternalServerError, | ||||||
|  | 				model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		respond.JSON(w, http.StatusOK, model.NewUser(user, false)) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -27,6 +27,7 @@ type PlainOauthAuthenticator struct { | ||||||
| 	userAdminMapping     *config.OauthAdminMapping | 	userAdminMapping     *config.OauthAdminMapping | ||||||
| 	registrationEnabled  bool | 	registrationEnabled  bool | ||||||
| 	userInfoLogging      bool | 	userInfoLogging      bool | ||||||
|  | 	sensitiveInfoLogging bool | ||||||
| 	allowedDomains       []string | 	allowedDomains       []string | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -57,6 +58,7 @@ func newPlainOauthAuthenticator( | ||||||
| 	provider.userAdminMapping = &cfg.AdminMapping | 	provider.userAdminMapping = &cfg.AdminMapping | ||||||
| 	provider.registrationEnabled = cfg.RegistrationEnabled | 	provider.registrationEnabled = cfg.RegistrationEnabled | ||||||
| 	provider.userInfoLogging = cfg.LogUserInfo | 	provider.userInfoLogging = cfg.LogUserInfo | ||||||
|  | 	provider.sensitiveInfoLogging = cfg.LogSensitiveInfo | ||||||
| 	provider.allowedDomains = cfg.AllowedDomains | 	provider.allowedDomains = cfg.AllowedDomains | ||||||
| 
 | 
 | ||||||
| 	return provider, nil | 	return provider, nil | ||||||
|  | @ -110,6 +112,10 @@ func (p PlainOauthAuthenticator) GetUserInfo( | ||||||
| 
 | 
 | ||||||
| 	response, err := p.client.Do(req) | 	response, err := p.client.Do(req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | 		if p.sensitiveInfoLogging { | ||||||
|  | 			slog.Debug("OAuth: failed to get user info", "endpoint", p.userInfoEndpoint, | ||||||
|  | 				"token", token, "error", err) | ||||||
|  | 		} | ||||||
| 		return nil, fmt.Errorf("failed to get user info: %w", err) | 		return nil, fmt.Errorf("failed to get user info: %w", err) | ||||||
| 	} | 	} | ||||||
| 	defer internal.LogClose(response.Body) | 	defer internal.LogClose(response.Body) | ||||||
|  | @ -121,11 +127,15 @@ func (p PlainOauthAuthenticator) GetUserInfo( | ||||||
| 	var userFields map[string]any | 	var userFields map[string]any | ||||||
| 	err = json.Unmarshal(contents, &userFields) | 	err = json.Unmarshal(contents, &userFields) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | 		if p.sensitiveInfoLogging { | ||||||
|  | 			slog.Debug("OAuth: failed to parse user info", "endpoint", p.userInfoEndpoint, | ||||||
|  | 				"token", token, "contents", contents, "error", err) | ||||||
|  | 		} | ||||||
| 		return nil, fmt.Errorf("failed to parse user info: %w", err) | 		return nil, fmt.Errorf("failed to parse user info: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if p.userInfoLogging { | 	if p.userInfoLogging { | ||||||
| 		slog.Debug("OAuth user info", | 		slog.Debug("OAuth: user info debug", | ||||||
| 			"source", p.name, | 			"source", p.name, | ||||||
| 			"info", string(contents)) | 			"info", string(contents)) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -24,6 +24,7 @@ type OidcAuthenticator struct { | ||||||
| 	userAdminMapping     *config.OauthAdminMapping | 	userAdminMapping     *config.OauthAdminMapping | ||||||
| 	registrationEnabled  bool | 	registrationEnabled  bool | ||||||
| 	userInfoLogging      bool | 	userInfoLogging      bool | ||||||
|  | 	sensitiveInfoLogging bool | ||||||
| 	allowedDomains       []string | 	allowedDomains       []string | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -58,6 +59,7 @@ func newOidcAuthenticator( | ||||||
| 	provider.userAdminMapping = &cfg.AdminMapping | 	provider.userAdminMapping = &cfg.AdminMapping | ||||||
| 	provider.registrationEnabled = cfg.RegistrationEnabled | 	provider.registrationEnabled = cfg.RegistrationEnabled | ||||||
| 	provider.userInfoLogging = cfg.LogUserInfo | 	provider.userInfoLogging = cfg.LogUserInfo | ||||||
|  | 	provider.sensitiveInfoLogging = cfg.LogSensitiveInfo | ||||||
| 	provider.allowedDomains = cfg.AllowedDomains | 	provider.allowedDomains = cfg.AllowedDomains | ||||||
| 
 | 
 | ||||||
| 	return provider, nil | 	return provider, nil | ||||||
|  | @ -102,24 +104,40 @@ func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token, | ||||||
| ) { | ) { | ||||||
| 	rawIDToken, ok := token.Extra("id_token").(string) | 	rawIDToken, ok := token.Extra("id_token").(string) | ||||||
| 	if !ok { | 	if !ok { | ||||||
|  | 		if o.sensitiveInfoLogging { | ||||||
|  | 			slog.Debug("OIDC: token does not contain id_token", "token", token, "nonce", nonce) | ||||||
|  | 		} | ||||||
| 		return nil, errors.New("token does not contain id_token") | 		return nil, errors.New("token does not contain id_token") | ||||||
| 	} | 	} | ||||||
| 	idToken, err := o.verifier.Verify(ctx, rawIDToken) | 	idToken, err := o.verifier.Verify(ctx, rawIDToken) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | 		if o.sensitiveInfoLogging { | ||||||
|  | 			slog.Debug("OIDC: failed to validate id_token", "token", token, "id_token", rawIDToken, "nonce", nonce, | ||||||
|  | 				"error", | ||||||
|  | 				err) | ||||||
|  | 		} | ||||||
| 		return nil, fmt.Errorf("failed to validate id_token: %w", err) | 		return nil, fmt.Errorf("failed to validate id_token: %w", err) | ||||||
| 	} | 	} | ||||||
| 	if idToken.Nonce != nonce { | 	if idToken.Nonce != nonce { | ||||||
|  | 		if o.sensitiveInfoLogging { | ||||||
|  | 			slog.Debug("OIDC: id_token nonce mismatch", "token", token, "id_token", idToken, "nonce", nonce) | ||||||
|  | 		} | ||||||
| 		return nil, errors.New("nonce mismatch") | 		return nil, errors.New("nonce mismatch") | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	var tokenFields map[string]any | 	var tokenFields map[string]any | ||||||
| 	if err = idToken.Claims(&tokenFields); err != nil { | 	if err = idToken.Claims(&tokenFields); err != nil { | ||||||
|  | 		if o.sensitiveInfoLogging { | ||||||
|  | 			slog.Debug("OIDC: failed to parse extra claims", "token", token, "id_token", idToken, "nonce", nonce, | ||||||
|  | 				"error", | ||||||
|  | 				err) | ||||||
|  | 		} | ||||||
| 		return nil, fmt.Errorf("failed to parse extra claims: %w", err) | 		return nil, fmt.Errorf("failed to parse extra claims: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if o.userInfoLogging { | 	if o.userInfoLogging { | ||||||
| 		contents, _ := json.Marshal(tokenFields) | 		contents, _ := json.Marshal(tokenFields) | ||||||
| 		slog.Debug("OIDC user info", | 		slog.Debug("OIDC: user info debug", | ||||||
| 			"source", o.name, | 			"source", o.name, | ||||||
| 			"info", string(contents)) | 			"info", string(contents)) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -5,6 +5,7 @@ import ( | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"io" | 	"io" | ||||||
| 	"log/slog" | 	"log/slog" | ||||||
|  | 	"net/mail" | ||||||
| 
 | 
 | ||||||
| 	"github.com/h44z/wg-portal/internal/config" | 	"github.com/h44z/wg-portal/internal/config" | ||||||
| 	"github.com/h44z/wg-portal/internal/domain" | 	"github.com/h44z/wg-portal/internal/domain" | ||||||
|  | @ -101,29 +102,15 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, style string, | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		if peer.UserIdentifier == "" { | 		if peer.UserIdentifier == "" { | ||||||
| 			slog.Debug("skipping peer email", | 			return fmt.Errorf("peer %s has no user linked, no email is sent", peerId) | ||||||
| 				"peer", peerId, |  | ||||||
| 				"reason", "no user linked") |  | ||||||
| 			continue |  | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		user, err := m.users.GetUser(ctx, peer.UserIdentifier) | 		email, user := m.resolveEmail(ctx, peer) | ||||||
| 		if err != nil { | 		if email == "" { | ||||||
| 			slog.Debug("skipping peer email", | 			return fmt.Errorf("peer %s has no valid email address, no email is sent", peerId) | ||||||
| 				"peer", peerId, |  | ||||||
| 				"reason", "unable to fetch user", |  | ||||||
| 				"error", err) |  | ||||||
| 			continue |  | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		if user.Email == "" { | 		err = m.sendPeerEmail(ctx, linkOnly, style, &user, peer) | ||||||
| 			slog.Debug("skipping peer email", |  | ||||||
| 				"peer", peerId, |  | ||||||
| 				"reason", "user has no mail address") |  | ||||||
| 			continue |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		err = m.sendPeerEmail(ctx, linkOnly, style, user, peer) |  | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return fmt.Errorf("failed to send peer email for %s: %w", peerId, err) | 			return fmt.Errorf("failed to send peer email for %s: %w", peerId, err) | ||||||
| 		} | 		} | ||||||
|  | @ -194,3 +181,37 @@ func (m Manager) sendPeerEmail( | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func (m Manager) resolveEmail(ctx context.Context, peer *domain.Peer) (string, domain.User) { | ||||||
|  | 	user, err := m.users.GetUser(ctx, peer.UserIdentifier) | ||||||
|  | 	if err != nil { | ||||||
|  | 		if m.cfg.Mail.AllowPeerEmail { | ||||||
|  | 			_, err := mail.ParseAddress(string(peer.UserIdentifier)) // test if the user identifier is a valid email address
 | ||||||
|  | 			if err == nil { | ||||||
|  | 				slog.Debug("peer email: using user-identifier as email", | ||||||
|  | 					"peer", peer.Identifier, "email", peer.UserIdentifier) | ||||||
|  | 				return string(peer.UserIdentifier), domain.User{} | ||||||
|  | 			} else { | ||||||
|  | 				slog.Debug("peer email: skipping peer email", | ||||||
|  | 					"peer", peer.Identifier, | ||||||
|  | 					"reason", "peer has no user linked and user-identifier is not a valid email address") | ||||||
|  | 				return "", domain.User{} | ||||||
|  | 			} | ||||||
|  | 		} else { | ||||||
|  | 			slog.Debug("peer email: skipping peer email", | ||||||
|  | 				"peer", peer.Identifier, | ||||||
|  | 				"reason", "user has no user linked") | ||||||
|  | 			return "", domain.User{} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if user.Email == "" { | ||||||
|  | 		slog.Debug("peer email: skipping peer email", | ||||||
|  | 			"peer", peer.Identifier, | ||||||
|  | 			"reason", "user has no mail address") | ||||||
|  | 		return "", domain.User{} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	slog.Debug("peer email: using user email", "peer", peer.Identifier, "email", user.Email) | ||||||
|  | 	return user.Email, *user | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -4,25 +4,23 @@ import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"log/slog" | 	"log/slog" | ||||||
| 
 | 	"sync" | ||||||
| 	"github.com/vishvananda/netlink" |  | ||||||
| 	"golang.org/x/sys/unix" |  | ||||||
| 	"golang.zx2c4.com/wireguard/wgctrl" |  | ||||||
| 	"golang.zx2c4.com/wireguard/wgctrl/wgtypes" |  | ||||||
| 
 | 
 | ||||||
| 	"github.com/h44z/wg-portal/internal/app" | 	"github.com/h44z/wg-portal/internal/app" | ||||||
| 	"github.com/h44z/wg-portal/internal/config" | 	"github.com/h44z/wg-portal/internal/config" | ||||||
| 	"github.com/h44z/wg-portal/internal/domain" | 	"github.com/h44z/wg-portal/internal/domain" | ||||||
| 	"github.com/h44z/wg-portal/internal/lowlevel" |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // region dependencies
 | // region dependencies
 | ||||||
| 
 | 
 | ||||||
|  | type ControllerManager interface { | ||||||
|  | 	// GetController returns the controller for the given interface.
 | ||||||
|  | 	GetController(iface domain.Interface) domain.InterfaceController | ||||||
|  | } | ||||||
|  | 
 | ||||||
| type InterfaceAndPeerDatabaseRepo interface { | type InterfaceAndPeerDatabaseRepo interface { | ||||||
| 	// GetAllInterfaces returns all interfaces
 | 	// GetInterface returns the interface with the given identifier.
 | ||||||
| 	GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) | 	GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) | ||||||
| 	// GetInterfacePeers returns all peers for a given interface
 |  | ||||||
| 	GetInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.Peer, error) |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type EventBus interface { | type EventBus interface { | ||||||
|  | @ -30,6 +28,13 @@ type EventBus interface { | ||||||
| 	Subscribe(topic string, fn interface{}) error | 	Subscribe(topic string, fn interface{}) error | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | type RoutesController interface { | ||||||
|  | 	// SetRoutes sets the routes for the given interface. If no routes are provided, the function is a no-op.
 | ||||||
|  | 	SetRoutes(ctx context.Context, info domain.RoutingTableInfo) error | ||||||
|  | 	// RemoveRoutes removes the routes for the given interface. If no routes are provided, the function is a no-op.
 | ||||||
|  | 	RemoveRoutes(ctx context.Context, info domain.RoutingTableInfo) error | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // endregion dependencies
 | // endregion dependencies
 | ||||||
| 
 | 
 | ||||||
| type routeRuleInfo struct { | type routeRuleInfo struct { | ||||||
|  | @ -46,27 +51,26 @@ type Manager struct { | ||||||
| 	cfg *config.Config | 	cfg *config.Config | ||||||
| 
 | 
 | ||||||
| 	bus          EventBus | 	bus          EventBus | ||||||
| 	wg  lowlevel.WireGuardClient |  | ||||||
| 	nl  lowlevel.NetlinkClient |  | ||||||
| 	db           InterfaceAndPeerDatabaseRepo | 	db           InterfaceAndPeerDatabaseRepo | ||||||
|  | 	wgController ControllerManager | ||||||
|  | 
 | ||||||
|  | 	mux *sync.Mutex | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // NewRouteManager creates a new route manager instance.
 | // NewRouteManager creates a new route manager instance.
 | ||||||
| func NewRouteManager(cfg *config.Config, bus EventBus, db InterfaceAndPeerDatabaseRepo) (*Manager, error) { | func NewRouteManager( | ||||||
| 	wg, err := wgctrl.New() | 	cfg *config.Config, | ||||||
| 	if err != nil { | 	bus EventBus, | ||||||
| 		panic("failed to init wgctrl: " + err.Error()) | 	db InterfaceAndPeerDatabaseRepo, | ||||||
| 	} | 	wgController ControllerManager, | ||||||
| 
 | ) (*Manager, error) { | ||||||
| 	nl := &lowlevel.NetlinkManager{} |  | ||||||
| 
 |  | ||||||
| 	m := &Manager{ | 	m := &Manager{ | ||||||
| 		cfg: cfg, | 		cfg: cfg, | ||||||
| 		bus: bus, | 		bus: bus, | ||||||
| 
 | 
 | ||||||
| 		db:           db, | 		db:           db, | ||||||
| 		wg: wg, | 		wgController: wgController, | ||||||
| 		nl: nl, | 		mux:          &sync.Mutex{}, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	m.connectToMessageBus() | 	m.connectToMessageBus() | ||||||
|  | @ -85,419 +89,82 @@ func (m Manager) StartBackgroundJobs(_ context.Context) { | ||||||
| 	// this is a no-op for now
 | 	// this is a no-op for now
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (m Manager) handleRouteUpdateEvent(srcDescription string) { | func (m Manager) handleRouteUpdateEvent(info domain.RoutingTableInfo) { | ||||||
| 	slog.Debug("handling route update event", "source", srcDescription) | 	m.mux.Lock() // ensure that only one route update is processed at a time
 | ||||||
|  | 	defer m.mux.Unlock() | ||||||
| 
 | 
 | ||||||
| 	err := m.syncRoutes(context.Background()) | 	slog.Debug("handling route update event", "info", info.String()) | ||||||
| 	if err != nil { | 
 | ||||||
| 		slog.Error("failed to synchronize routes", | 	if !info.ManagementEnabled() { | ||||||
| 			"source", srcDescription, | 		return // route management disabled
 | ||||||
| 			"error", err) |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	slog.Debug("routes synchronized", "source", srcDescription) | 	err := m.syncRoutes(context.Background(), info) | ||||||
|  | 	if err != nil { | ||||||
|  | 		slog.Error("failed to synchronize routes", | ||||||
|  | 			"info", info.String(), "error", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	slog.Debug("routes synchronized", "info", info.String()) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (m Manager) handleRouteRemoveEvent(info domain.RoutingTableInfo) { | func (m Manager) handleRouteRemoveEvent(info domain.RoutingTableInfo) { | ||||||
|  | 	m.mux.Lock() // ensure that only one route update is processed at a time
 | ||||||
|  | 	defer m.mux.Unlock() | ||||||
|  | 
 | ||||||
| 	slog.Debug("handling route remove event", "info", info.String()) | 	slog.Debug("handling route remove event", "info", info.String()) | ||||||
| 
 | 
 | ||||||
| 	if !info.ManagementEnabled() { | 	if !info.ManagementEnabled() { | ||||||
| 		return // route management disabled
 | 		return // route management disabled
 | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if err := m.removeFwMarkRules(info.FwMark, info.GetRoutingTable(), netlink.FAMILY_V4); err != nil { | 	err := m.removeRoutes(context.Background(), info) | ||||||
| 		slog.Error("failed to remove v4 fwmark rules", "error", err) |  | ||||||
| 	} |  | ||||||
| 	if err := m.removeFwMarkRules(info.FwMark, info.GetRoutingTable(), netlink.FAMILY_V6); err != nil { |  | ||||||
| 		slog.Error("failed to remove v6 fwmark rules", "error", err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	slog.Debug("routes removed", "table", info.String()) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (m Manager) syncRoutes(ctx context.Context) error { |  | ||||||
| 	interfaces, err := m.db.GetAllInterfaces(ctx) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fmt.Errorf("failed to find all interfaces: %w", err) | 		slog.Error("failed to synchronize routes", | ||||||
| 	} | 			"info", info.String(), "error", err) | ||||||
| 
 |  | ||||||
| 	rules := map[int][]routeRuleInfo{ |  | ||||||
| 		netlink.FAMILY_V4: nil, |  | ||||||
| 		netlink.FAMILY_V6: nil, |  | ||||||
| 	} |  | ||||||
| 	for _, iface := range interfaces { |  | ||||||
| 		if iface.IsDisabled() { |  | ||||||
| 			continue // disabled interface does not need route entries
 |  | ||||||
| 		} |  | ||||||
| 		if !iface.ManageRoutingTable() { |  | ||||||
| 			continue |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return fmt.Errorf("failed to find peers for %s: %w", iface.Identifier, err) |  | ||||||
| 		} |  | ||||||
| 		allowedIPs := iface.GetAllowedIPs(peers) |  | ||||||
| 		defRouteV4, defRouteV6 := m.containsDefaultRoute(allowedIPs) |  | ||||||
| 
 |  | ||||||
| 		link, err := m.nl.LinkByName(string(iface.Identifier)) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return fmt.Errorf("failed to find physical link for %s: %w", iface.Identifier, err) |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		table, fwmark, err := m.getRoutingTableAndFwMark(&iface, link) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return fmt.Errorf("failed to get table and fwmark for %s: %w", iface.Identifier, err) |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		if err := m.setInterfaceRoutes(link, table, allowedIPs); err != nil { |  | ||||||
| 			return fmt.Errorf("failed to set routes for %s: %w", iface.Identifier, err) |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		if err := m.removeDeprecatedRoutes(link, netlink.FAMILY_V4, allowedIPs); err != nil { |  | ||||||
| 			return fmt.Errorf("failed to remove deprecated v4 routes for %s: %w", iface.Identifier, err) |  | ||||||
| 		} |  | ||||||
| 		if err := m.removeDeprecatedRoutes(link, netlink.FAMILY_V6, allowedIPs); err != nil { |  | ||||||
| 			return fmt.Errorf("failed to remove deprecated v6 routes for %s: %w", iface.Identifier, err) |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		if table != 0 { |  | ||||||
| 			rules[netlink.FAMILY_V4] = append(rules[netlink.FAMILY_V4], routeRuleInfo{ |  | ||||||
| 				ifaceId:    iface.Identifier, |  | ||||||
| 				fwMark:     fwmark, |  | ||||||
| 				table:      table, |  | ||||||
| 				family:     netlink.FAMILY_V4, |  | ||||||
| 				hasDefault: defRouteV4, |  | ||||||
| 			}) |  | ||||||
| 		} |  | ||||||
| 		if table != 0 { |  | ||||||
| 			rules[netlink.FAMILY_V6] = append(rules[netlink.FAMILY_V6], routeRuleInfo{ |  | ||||||
| 				ifaceId:    iface.Identifier, |  | ||||||
| 				fwMark:     fwmark, |  | ||||||
| 				table:      table, |  | ||||||
| 				family:     netlink.FAMILY_V6, |  | ||||||
| 				hasDefault: defRouteV6, |  | ||||||
| 			}) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return m.syncRouteRules(rules) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (m Manager) syncRouteRules(allRules map[int][]routeRuleInfo) error { |  | ||||||
| 	for family, rules := range allRules { |  | ||||||
| 		// update fwmark rules
 |  | ||||||
| 		if err := m.setFwMarkRules(rules, family); err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		// update main rule
 |  | ||||||
| 		if err := m.setMainRule(rules, family); err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		// cleanup old main rules
 |  | ||||||
| 		if err := m.cleanupMainRule(rules, family); err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (m Manager) setFwMarkRules(rules []routeRuleInfo, family int) error { |  | ||||||
| 	for _, rule := range rules { |  | ||||||
| 		existingRules, err := m.nl.RuleList(family) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return fmt.Errorf("failed to get existing rules for family %d: %w", family, err) |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		ruleExists := false |  | ||||||
| 		for _, existingRule := range existingRules { |  | ||||||
| 			if rule.fwMark == existingRule.Mark && rule.table == existingRule.Table { |  | ||||||
| 				ruleExists = true |  | ||||||
| 				break |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		if ruleExists { |  | ||||||
| 			continue // rule already exists, no need to recreate it
 |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		// create missing rule
 |  | ||||||
| 		if err := m.nl.RuleAdd(&netlink.Rule{ |  | ||||||
| 			Family:            family, |  | ||||||
| 			Table:             rule.table, |  | ||||||
| 			Mark:              rule.fwMark, |  | ||||||
| 			Invert:            true, |  | ||||||
| 			SuppressIfgroup:   -1, |  | ||||||
| 			SuppressPrefixlen: -1, |  | ||||||
| 			Priority:          m.getRulePriority(existingRules), |  | ||||||
| 			Mask:              nil, |  | ||||||
| 			Goto:              -1, |  | ||||||
| 			Flow:              -1, |  | ||||||
| 		}); err != nil { |  | ||||||
| 			return fmt.Errorf("failed to setup rule for fwmark %d and table %d: %w", rule.fwMark, rule.table, err) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (m Manager) removeFwMarkRules(fwmark uint32, table int, family int) error { |  | ||||||
| 	existingRules, err := m.nl.RuleList(family) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return fmt.Errorf("failed to get existing rules for family %d: %w", family, err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	for _, existingRule := range existingRules { |  | ||||||
| 		if fwmark == existingRule.Mark && table == existingRule.Table { |  | ||||||
| 			existingRule.Family = family // set family, somehow the RuleList method does not populate the family field
 |  | ||||||
| 			if err := m.nl.RuleDel(&existingRule); err != nil { |  | ||||||
| 				return fmt.Errorf("failed to delete fwmark rule: %w", err) |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (m Manager) setMainRule(rules []routeRuleInfo, family int) error { |  | ||||||
| 	shouldHaveMainRule := false |  | ||||||
| 	for _, rule := range rules { |  | ||||||
| 		if rule.hasDefault == true { |  | ||||||
| 			shouldHaveMainRule = true |  | ||||||
| 			break |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	if !shouldHaveMainRule { |  | ||||||
| 		return nil |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	existingRules, err := m.nl.RuleList(family) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return fmt.Errorf("failed to get existing rules for family %d: %w", family, err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	ruleExists := false |  | ||||||
| 	for _, existingRule := range existingRules { |  | ||||||
| 		if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 { |  | ||||||
| 			ruleExists = true |  | ||||||
| 			break |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if ruleExists { |  | ||||||
| 		return nil // rule already exists, skip re-creation
 |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if err := m.nl.RuleAdd(&netlink.Rule{ |  | ||||||
| 		Family:            family, |  | ||||||
| 		Table:             unix.RT_TABLE_MAIN, |  | ||||||
| 		SuppressIfgroup:   -1, |  | ||||||
| 		SuppressPrefixlen: 0, |  | ||||||
| 		Priority:          m.getMainRulePriority(existingRules), |  | ||||||
| 		Mark:              0, |  | ||||||
| 		Mask:              nil, |  | ||||||
| 		Goto:              -1, |  | ||||||
| 		Flow:              -1, |  | ||||||
| 	}); err != nil { |  | ||||||
| 		return fmt.Errorf("failed to setup rule for main table: %w", err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (m Manager) cleanupMainRule(rules []routeRuleInfo, family int) error { |  | ||||||
| 	existingRules, err := m.nl.RuleList(family) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return fmt.Errorf("failed to get existing rules for family %d: %w", family, err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	shouldHaveMainRule := false |  | ||||||
| 	for _, rule := range rules { |  | ||||||
| 		if rule.hasDefault == true { |  | ||||||
| 			shouldHaveMainRule = true |  | ||||||
| 			break |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	mainRules := 0 |  | ||||||
| 	for _, existingRule := range existingRules { |  | ||||||
| 		if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 { |  | ||||||
| 			mainRules++ |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	removalCount := 0 |  | ||||||
| 	if mainRules > 1 { |  | ||||||
| 		removalCount = mainRules - 1 // we only want one single rule
 |  | ||||||
| 	} |  | ||||||
| 	if !shouldHaveMainRule { |  | ||||||
| 		removalCount = mainRules |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	for _, existingRule := range existingRules { |  | ||||||
| 		if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 { |  | ||||||
| 			if removalCount > 0 { |  | ||||||
| 				existingRule.Family = family // set family, somehow the RuleList method does not populate the family field
 |  | ||||||
| 				if err := m.nl.RuleDel(&existingRule); err != nil { |  | ||||||
| 					return fmt.Errorf("failed to delete main rule: %w", err) |  | ||||||
| 				} |  | ||||||
| 				removalCount-- |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (m Manager) getMainRulePriority(existingRules []netlink.Rule) int { |  | ||||||
| 	prio := m.cfg.Advanced.RulePrioOffset |  | ||||||
| 	for { |  | ||||||
| 		isFresh := true |  | ||||||
| 		for _, existingRule := range existingRules { |  | ||||||
| 			if existingRule.Priority == prio { |  | ||||||
| 				isFresh = false |  | ||||||
| 				break |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		if isFresh { |  | ||||||
| 			break |  | ||||||
| 		} else { |  | ||||||
| 			prio++ |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return prio |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (m Manager) getRulePriority(existingRules []netlink.Rule) int { |  | ||||||
| 	prio := 32700 // linux main rule has a prio of 32766
 |  | ||||||
| 	for { |  | ||||||
| 		isFresh := true |  | ||||||
| 		for _, existingRule := range existingRules { |  | ||||||
| 			if existingRule.Priority == prio { |  | ||||||
| 				isFresh = false |  | ||||||
| 				break |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		if isFresh { |  | ||||||
| 			break |  | ||||||
| 		} else { |  | ||||||
| 			prio-- |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return prio |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (m Manager) setInterfaceRoutes(link netlink.Link, table int, allowedIPs []domain.Cidr) error { |  | ||||||
| 	for _, allowedIP := range allowedIPs { |  | ||||||
| 		err := m.nl.RouteReplace(&netlink.Route{ |  | ||||||
| 			LinkIndex: link.Attrs().Index, |  | ||||||
| 			Dst:       allowedIP.IpNet(), |  | ||||||
| 			Table:     table, |  | ||||||
| 			Scope:     unix.RT_SCOPE_LINK, |  | ||||||
| 			Type:      unix.RTN_UNICAST, |  | ||||||
| 		}) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return fmt.Errorf("failed to add/update route %s: %w", allowedIP.String(), err) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (m Manager) removeDeprecatedRoutes(link netlink.Link, family int, allowedIPs []domain.Cidr) error { |  | ||||||
| 	rawRoutes, err := m.nl.RouteListFiltered(family, &netlink.Route{ |  | ||||||
| 		LinkIndex: link.Attrs().Index, |  | ||||||
| 		Table:     unix.RT_TABLE_UNSPEC, // all tables
 |  | ||||||
| 		Scope:     unix.RT_SCOPE_LINK, |  | ||||||
| 		Type:      unix.RTN_UNICAST, |  | ||||||
| 	}, netlink.RT_FILTER_TABLE|netlink.RT_FILTER_TYPE|netlink.RT_FILTER_OIF) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return fmt.Errorf("failed to fetch raw routes: %w", err) |  | ||||||
| 	} |  | ||||||
| 	for _, rawRoute := range rawRoutes { |  | ||||||
| 		if rawRoute.Dst == nil { // handle default route
 |  | ||||||
| 			var netlinkAddr domain.Cidr |  | ||||||
| 			if family == netlink.FAMILY_V4 { |  | ||||||
| 				netlinkAddr, _ = domain.CidrFromString("0.0.0.0/0") |  | ||||||
| 			} else { |  | ||||||
| 				netlinkAddr, _ = domain.CidrFromString("::/0") |  | ||||||
| 			} |  | ||||||
| 			rawRoute.Dst = netlinkAddr.IpNet() |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		netlinkAddr := domain.CidrFromIpNet(*rawRoute.Dst) |  | ||||||
| 		remove := true |  | ||||||
| 		for _, allowedIP := range allowedIPs { |  | ||||||
| 			if netlinkAddr == allowedIP { |  | ||||||
| 				remove = false |  | ||||||
| 				break |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		if !remove { |  | ||||||
| 			continue |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		err := m.nl.RouteDel(&rawRoute) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return fmt.Errorf("failed to remove deprecated route %s: %w", netlinkAddr.String(), err) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (m Manager) getRoutingTableAndFwMark(iface *domain.Interface, link netlink.Link) ( |  | ||||||
| 	table int, |  | ||||||
| 	fwmark uint32, |  | ||||||
| 	err error, |  | ||||||
| ) { |  | ||||||
| 	table = iface.GetRoutingTable() |  | ||||||
| 	fwmark = iface.FirewallMark |  | ||||||
| 
 |  | ||||||
| 	if fwmark == 0 { |  | ||||||
| 		// generate a new (temporary) firewall mark based on the interface index
 |  | ||||||
| 		fwmark = uint32(m.cfg.Advanced.RouteTableOffset + link.Attrs().Index) |  | ||||||
| 		slog.Debug("using fwmark to handle routes", |  | ||||||
| 			"interface", iface.Identifier, |  | ||||||
| 			"fwmark", fwmark) |  | ||||||
| 
 |  | ||||||
| 		// apply the temporary fwmark to the wireguard interface
 |  | ||||||
| 		err = m.setFwMark(iface.Identifier, int(fwmark)) |  | ||||||
| 	} |  | ||||||
| 	if table == 0 { |  | ||||||
| 		table = int(fwmark) // generate a new routing table base on interface index
 |  | ||||||
| 		slog.Debug("using routing table to handle default routes", |  | ||||||
| 			"interface", iface.Identifier, |  | ||||||
| 			"table", table) |  | ||||||
| 	} |  | ||||||
| 		return | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	slog.Debug("routes removed", "info", info.String()) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (m Manager) setFwMark(id domain.InterfaceIdentifier, fwmark int) error { | func (m Manager) syncRoutes(ctx context.Context, info domain.RoutingTableInfo) error { | ||||||
| 	err := m.wg.ConfigureDevice(string(id), wgtypes.Config{ | 	rc, ok := m.wgController.GetController(info.Interface).(RoutesController) | ||||||
| 		FirewallMark: &fwmark, | 	if !ok { | ||||||
| 	}) | 		slog.Warn("no capable routes-controller found for interface", "interface", info.Interface.Identifier) | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if !info.Interface.ManageRoutingTable() { | ||||||
|  | 		slog.Debug("interface does not manage routing table, skipping route update", | ||||||
|  | 			"interface", info.Interface.Identifier) | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	err := rc.SetRoutes(ctx, info) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fmt.Errorf("failed to update fwmark to: %d: %w", fwmark, err) | 		return fmt.Errorf("failed to set routes for interface %s: %w", info.Interface.Identifier, err) | ||||||
| 	} | 	} | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (m Manager) containsDefaultRoute(allowedIPs []domain.Cidr) (ipV4, ipV6 bool) { | func (m Manager) removeRoutes(ctx context.Context, info domain.RoutingTableInfo) error { | ||||||
| 	for _, allowedIP := range allowedIPs { | 	rc, ok := m.wgController.GetController(info.Interface).(RoutesController) | ||||||
| 		if ipV4 && ipV6 { | 	if !ok { | ||||||
| 			break // speed up
 | 		slog.Warn("no capable routes-controller found for interface", "interface", info.Interface.Identifier) | ||||||
|  | 		return nil | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 		if allowedIP.Prefix().Bits() == 0 { | 	if !info.Interface.ManageRoutingTable() { | ||||||
| 			if allowedIP.IsV4() { | 		slog.Debug("interface does not manage routing table, skipping route removal", | ||||||
| 				ipV4 = true | 			"interface", info.Interface.Identifier) | ||||||
| 			} else { | 		return nil | ||||||
| 				ipV6 = true |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return | 	err := rc.RemoveRoutes(ctx, info) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to remove routes for interface %s: %w", info.Interface.Identifier, err) | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,7 +1,6 @@ | ||||||
| package wireguard | package wireguard | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" |  | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"log/slog" | 	"log/slog" | ||||||
| 	"maps" | 	"maps" | ||||||
|  | @ -12,33 +11,9 @@ import ( | ||||||
| 	"github.com/h44z/wg-portal/internal/domain" | 	"github.com/h44z/wg-portal/internal/domain" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type InterfaceController interface { |  | ||||||
| 	GetId() domain.InterfaceBackend |  | ||||||
| 	GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) |  | ||||||
| 	GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) |  | ||||||
| 	GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) |  | ||||||
| 	SaveInterface( |  | ||||||
| 		_ context.Context, |  | ||||||
| 		id domain.InterfaceIdentifier, |  | ||||||
| 		updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error), |  | ||||||
| 	) error |  | ||||||
| 	DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error |  | ||||||
| 	SavePeer( |  | ||||||
| 		_ context.Context, |  | ||||||
| 		deviceId domain.InterfaceIdentifier, |  | ||||||
| 		id domain.PeerIdentifier, |  | ||||||
| 		updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error), |  | ||||||
| 	) error |  | ||||||
| 	DeletePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error |  | ||||||
| 	PingAddresses( |  | ||||||
| 		ctx context.Context, |  | ||||||
| 		addr string, |  | ||||||
| 	) (*domain.PingerResult, error) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| type backendInstance struct { | type backendInstance struct { | ||||||
| 	Config         config.BackendBase // Config is the configuration for the backend instance.
 | 	Config         config.BackendBase // Config is the configuration for the backend instance.
 | ||||||
| 	Implementation InterfaceController | 	Implementation domain.InterfaceController | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type ControllerManager struct { | type ControllerManager struct { | ||||||
|  | @ -118,11 +93,11 @@ func (c *ControllerManager) logRegisteredControllers() { | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c *ControllerManager) GetControllerByName(backend domain.InterfaceBackend) InterfaceController { | func (c *ControllerManager) GetControllerByName(backend domain.InterfaceBackend) domain.InterfaceController { | ||||||
| 	return c.getController(backend, "").Implementation | 	return c.getController(backend, "").Implementation | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c *ControllerManager) GetController(iface domain.Interface) InterfaceController { | func (c *ControllerManager) GetController(iface domain.Interface) domain.InterfaceController { | ||||||
| 	return c.getController(iface.Backend, iface.Identifier).Implementation | 	return c.getController(iface.Backend, iface.Identifier).Implementation | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -38,9 +38,9 @@ type InterfaceAndPeerDatabaseRepo interface { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type WgQuickController interface { | type WgQuickController interface { | ||||||
| 	ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error | 	ExecuteInterfaceHook(ctx context.Context, id domain.InterfaceIdentifier, hookCmd string) error | ||||||
| 	SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error | 	SetDNS(ctx context.Context, id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error | ||||||
| 	UnsetDNS(id domain.InterfaceIdentifier) error | 	UnsetDNS(ctx context.Context, id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type EventBus interface { | type EventBus interface { | ||||||
|  | @ -57,7 +57,6 @@ type Manager struct { | ||||||
| 	bus EventBus | 	bus EventBus | ||||||
| 	db  InterfaceAndPeerDatabaseRepo | 	db  InterfaceAndPeerDatabaseRepo | ||||||
| 	wg  *ControllerManager | 	wg  *ControllerManager | ||||||
| 	quick WgQuickController |  | ||||||
| 
 | 
 | ||||||
| 	userLockMap *sync.Map | 	userLockMap *sync.Map | ||||||
| } | } | ||||||
|  | @ -66,7 +65,6 @@ func NewWireGuardManager( | ||||||
| 	cfg *config.Config, | 	cfg *config.Config, | ||||||
| 	bus EventBus, | 	bus EventBus, | ||||||
| 	wg *ControllerManager, | 	wg *ControllerManager, | ||||||
| 	quick WgQuickController, |  | ||||||
| 	db InterfaceAndPeerDatabaseRepo, | 	db InterfaceAndPeerDatabaseRepo, | ||||||
| ) (*Manager, error) { | ) (*Manager, error) { | ||||||
| 	m := &Manager{ | 	m := &Manager{ | ||||||
|  | @ -74,7 +72,6 @@ func NewWireGuardManager( | ||||||
| 		bus:         bus, | 		bus:         bus, | ||||||
| 		wg:          wg, | 		wg:          wg, | ||||||
| 		db:          db, | 		db:          db, | ||||||
| 		quick:       quick, |  | ||||||
| 		userLockMap: &sync.Map{}, | 		userLockMap: &sync.Map{}, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -453,7 +453,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	existingInterface, err := m.db.GetInterface(ctx, id) | 	existingInterface, existingPeers, err := m.db.GetInterfaceAndPeers(ctx, id) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fmt.Errorf("unable to find interface %s: %w", id, err) | 		return fmt.Errorf("unable to find interface %s: %w", id, err) | ||||||
| 	} | 	} | ||||||
|  | @ -462,21 +462,29 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif | ||||||
| 		return fmt.Errorf("deletion not allowed: %w", err) | 		return fmt.Errorf("deletion not allowed: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{ | ||||||
|  | 		Interface:  *existingInterface, | ||||||
|  | 		AllowedIps: existingInterface.GetAllowedIPs(existingPeers), | ||||||
|  | 		FwMark:     existingInterface.FirewallMark, | ||||||
|  | 		Table:      existingInterface.GetRoutingTable(), | ||||||
|  | 		TableStr:   existingInterface.RoutingTable, | ||||||
|  | 		IsDeleted:  true, | ||||||
|  | 	}) | ||||||
|  | 
 | ||||||
| 	now := time.Now() | 	now := time.Now() | ||||||
| 	existingInterface.Disabled = &now // simulate a disabled interface
 | 	existingInterface.Disabled = &now // simulate a disabled interface
 | ||||||
| 	existingInterface.DisabledReason = domain.DisabledReasonDeleted | 	existingInterface.DisabledReason = domain.DisabledReasonDeleted | ||||||
| 
 | 
 | ||||||
| 	physicalInterface, _ := m.wg.GetController(*existingInterface).GetInterface(ctx, id) | 	if err := m.handleInterfacePreSaveHooks(ctx, existingInterface, !existingInterface.IsDisabled(), | ||||||
| 
 | 		false); err != nil { | ||||||
| 	if err := m.handleInterfacePreSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil { |  | ||||||
| 		return fmt.Errorf("pre-delete hooks failed: %w", err) | 		return fmt.Errorf("pre-delete hooks failed: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if err := m.handleInterfacePreSaveActions(existingInterface); err != nil { | 	if err := m.handleInterfacePreSaveActions(ctx, existingInterface); err != nil { | ||||||
| 		return fmt.Errorf("pre-delete actions failed: %w", err) | 		return fmt.Errorf("pre-delete actions failed: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if err := m.deleteInterfacePeers(ctx, id); err != nil { | 	if err := m.deleteInterfacePeers(ctx, existingInterface, existingPeers); err != nil { | ||||||
| 		return fmt.Errorf("peer deletion failure: %w", err) | 		return fmt.Errorf("peer deletion failure: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | @ -488,16 +496,12 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif | ||||||
| 		return fmt.Errorf("deletion failure: %w", err) | 		return fmt.Errorf("deletion failure: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	fwMark := existingInterface.FirewallMark | 	if err := m.handleInterfacePostSaveHooks( | ||||||
| 	if physicalInterface != nil && fwMark == 0 { | 		ctx, | ||||||
| 		fwMark = physicalInterface.FirewallMark | 		existingInterface, | ||||||
| 	} | 		!existingInterface.IsDisabled(), | ||||||
| 	m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{ | 		false, | ||||||
| 		FwMark: fwMark, | 	); err != nil { | ||||||
| 		Table:  existingInterface.GetRoutingTable(), |  | ||||||
| 	}) |  | ||||||
| 
 |  | ||||||
| 	if err := m.handleInterfacePostSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil { |  | ||||||
| 		return fmt.Errorf("post-delete hooks failed: %w", err) | 		return fmt.Errorf("post-delete hooks failed: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | @ -516,17 +520,21 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) ( | ||||||
| 		return nil, fmt.Errorf("interface validation failed: %w", err) | 		return nil, fmt.Errorf("interface validation failed: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	oldEnabled, newEnabled := m.getInterfaceStateHistory(ctx, iface) | 	oldEnabled, newEnabled, routeTableChanged := false, !iface.IsDisabled(), false // if the interface did not exist, we assume it was not enabled
 | ||||||
|  | 	oldInterface, err := m.db.GetInterface(ctx, iface.Identifier) | ||||||
|  | 	if err == nil { | ||||||
|  | 		oldEnabled, newEnabled, routeTableChanged = m.getInterfaceStateHistory(oldInterface, iface) | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	if err := m.handleInterfacePreSaveHooks(iface, oldEnabled, newEnabled); err != nil { | 	if err := m.handleInterfacePreSaveHooks(ctx, iface, oldEnabled, newEnabled); err != nil { | ||||||
| 		return nil, fmt.Errorf("pre-save hooks failed: %w", err) | 		return nil, fmt.Errorf("pre-save hooks failed: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if err := m.handleInterfacePreSaveActions(iface); err != nil { | 	if err := m.handleInterfacePreSaveActions(ctx, iface); err != nil { | ||||||
| 		return nil, fmt.Errorf("pre-save actions failed: %w", err) | 		return nil, fmt.Errorf("pre-save actions failed: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	err := m.db.SaveInterface(ctx, iface.Identifier, func(i *domain.Interface) (*domain.Interface, error) { | 	err = m.db.SaveInterface(ctx, iface.Identifier, func(i *domain.Interface) (*domain.Interface, error) { | ||||||
| 		iface.CopyCalculatedAttributes(i) | 		iface.CopyCalculatedAttributes(i) | ||||||
| 
 | 
 | ||||||
| 		err := m.wg.GetController(*iface).SaveInterface(ctx, iface.Identifier, | 		err := m.wg.GetController(*iface).SaveInterface(ctx, iface.Identifier, | ||||||
|  | @ -569,20 +577,35 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) ( | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if iface.IsDisabled() { | 	if iface.IsDisabled() { | ||||||
| 		physicalInterface, _ := m.wg.GetController(*iface).GetInterface(ctx, iface.Identifier) |  | ||||||
| 		fwMark := iface.FirewallMark |  | ||||||
| 		if physicalInterface != nil && fwMark == 0 { |  | ||||||
| 			fwMark = physicalInterface.FirewallMark |  | ||||||
| 		} |  | ||||||
| 		m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{ | 		m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{ | ||||||
| 			FwMark: fwMark, | 			Interface:  *iface, | ||||||
|  | 			AllowedIps: iface.GetAllowedIPs(peers), | ||||||
|  | 			FwMark:     iface.FirewallMark, | ||||||
| 			Table:      iface.GetRoutingTable(), | 			Table:      iface.GetRoutingTable(), | ||||||
|  | 			TableStr:   iface.RoutingTable, | ||||||
| 		}) | 		}) | ||||||
| 	} else { | 	} else { | ||||||
| 		m.bus.Publish(app.TopicRouteUpdate, "interface updated: "+string(iface.Identifier)) | 		m.bus.Publish(app.TopicRouteUpdate, domain.RoutingTableInfo{ | ||||||
|  | 			Interface:  *iface, | ||||||
|  | 			AllowedIps: iface.GetAllowedIPs(peers), | ||||||
|  | 			FwMark:     iface.FirewallMark, | ||||||
|  | 			Table:      iface.GetRoutingTable(), | ||||||
|  | 			TableStr:   iface.RoutingTable, | ||||||
|  | 		}) | ||||||
|  | 		// if the route table changed, ensure that the old entries are remove
 | ||||||
|  | 		if routeTableChanged { | ||||||
|  | 			m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{ | ||||||
|  | 				Interface:  *oldInterface, | ||||||
|  | 				AllowedIps: oldInterface.GetAllowedIPs(peers), | ||||||
|  | 				FwMark:     oldInterface.FirewallMark, | ||||||
|  | 				Table:      oldInterface.GetRoutingTable(), | ||||||
|  | 				TableStr:   oldInterface.RoutingTable, | ||||||
|  | 				IsDeleted:  true, // mark the old entries as deleted
 | ||||||
|  | 			}) | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if err := m.handleInterfacePostSaveHooks(iface, oldEnabled, newEnabled); err != nil { | 	if err := m.handleInterfacePostSaveHooks(ctx, iface, oldEnabled, newEnabled); err != nil { | ||||||
| 		return nil, fmt.Errorf("post-save hooks failed: %w", err) | 		return nil, fmt.Errorf("post-save hooks failed: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | @ -618,60 +641,90 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) ( | ||||||
| 	return iface, nil | 	return iface, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (m Manager) getInterfaceStateHistory(ctx context.Context, iface *domain.Interface) (oldEnabled, newEnabled bool) { | func (m Manager) getInterfaceStateHistory( | ||||||
| 	oldInterface, err := m.db.GetInterface(ctx, iface.Identifier) | 	oldInterface *domain.Interface, | ||||||
| 	if err != nil { | 	iface *domain.Interface, | ||||||
| 		return false, !iface.IsDisabled() // if the interface did not exist, we assume it was not enabled
 | ) (oldEnabled, newEnabled, routeTableChanged bool) { | ||||||
| 	} | 	return !oldInterface.IsDisabled(), !iface.IsDisabled(), oldInterface.RoutingTable != iface.RoutingTable | ||||||
| 
 |  | ||||||
| 	return !oldInterface.IsDisabled(), !iface.IsDisabled() |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (m Manager) handleInterfacePreSaveActions(iface *domain.Interface) error { | func (m Manager) handleInterfacePreSaveActions(ctx context.Context, iface *domain.Interface) error { | ||||||
|  | 	wgQuickController, ok := m.wg.GetController(*iface).(WgQuickController) | ||||||
|  | 	if !ok { | ||||||
|  | 		slog.Warn("failed to perform pre-save actions", "interface", iface.Identifier, | ||||||
|  | 			"error", "no capable controller found") | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// update DNS settings only for client interfaces
 | ||||||
|  | 	if iface.Type == domain.InterfaceTypeClient || iface.Type == domain.InterfaceTypeAny { | ||||||
| 		if !iface.IsDisabled() { | 		if !iface.IsDisabled() { | ||||||
| 		if err := m.quick.SetDNS(iface.Identifier, iface.DnsStr, iface.DnsSearchStr); err != nil { | 			if err := wgQuickController.SetDNS(ctx, iface.Identifier, iface.DnsStr, iface.DnsSearchStr); err != nil { | ||||||
| 				return fmt.Errorf("failed to update dns settings: %w", err) | 				return fmt.Errorf("failed to update dns settings: %w", err) | ||||||
| 			} | 			} | ||||||
| 		} else { | 		} else { | ||||||
| 		if err := m.quick.UnsetDNS(iface.Identifier); err != nil { | 			if err := wgQuickController.UnsetDNS(ctx, iface.Identifier, iface.DnsStr, iface.DnsSearchStr); err != nil { | ||||||
| 				return fmt.Errorf("failed to clear dns settings: %w", err) | 				return fmt.Errorf("failed to clear dns settings: %w", err) | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | 	} | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (m Manager) handleInterfacePreSaveHooks(iface *domain.Interface, oldEnabled, newEnabled bool) error { | func (m Manager) handleInterfacePreSaveHooks( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	iface *domain.Interface, | ||||||
|  | 	oldEnabled, newEnabled bool, | ||||||
|  | ) error { | ||||||
| 	if oldEnabled == newEnabled { | 	if oldEnabled == newEnabled { | ||||||
| 		return nil // do nothing if state did not change
 | 		return nil // do nothing if state did not change
 | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	slog.Debug("executing pre-save hooks", "interface", iface.Identifier, "up", newEnabled) | 	slog.Debug("executing pre-save hooks", "interface", iface.Identifier, "up", newEnabled) | ||||||
| 
 | 
 | ||||||
|  | 	wgQuickController, ok := m.wg.GetController(*iface).(WgQuickController) | ||||||
|  | 	if !ok { | ||||||
|  | 		slog.Warn("failed to execute pre-save hooks", "interface", iface.Identifier, "up", newEnabled, | ||||||
|  | 			"error", "no capable controller found") | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	if newEnabled { | 	if newEnabled { | ||||||
| 		if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PreUp); err != nil { | 		if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PreUp); err != nil { | ||||||
| 			return fmt.Errorf("failed to execute pre-up hook: %w", err) | 			return fmt.Errorf("failed to execute pre-up hook: %w", err) | ||||||
| 		} | 		} | ||||||
| 	} else { | 	} else { | ||||||
| 		if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PreDown); err != nil { | 		if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PreDown); err != nil { | ||||||
| 			return fmt.Errorf("failed to execute pre-down hook: %w", err) | 			return fmt.Errorf("failed to execute pre-down hook: %w", err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (m Manager) handleInterfacePostSaveHooks(iface *domain.Interface, oldEnabled, newEnabled bool) error { | func (m Manager) handleInterfacePostSaveHooks( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	iface *domain.Interface, | ||||||
|  | 	oldEnabled, newEnabled bool, | ||||||
|  | ) error { | ||||||
| 	if oldEnabled == newEnabled { | 	if oldEnabled == newEnabled { | ||||||
| 		return nil // do nothing if state did not change
 | 		return nil // do nothing if state did not change
 | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	slog.Debug("executing post-save hooks", "interface", iface.Identifier, "up", newEnabled) | 	slog.Debug("executing post-save hooks", "interface", iface.Identifier, "up", newEnabled) | ||||||
| 
 | 
 | ||||||
|  | 	wgQuickController, ok := m.wg.GetController(*iface).(WgQuickController) | ||||||
|  | 	if !ok { | ||||||
|  | 		slog.Warn("failed to execute post-save hooks", "interface", iface.Identifier, "up", newEnabled, | ||||||
|  | 			"error", "no capable controller found") | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	if newEnabled { | 	if newEnabled { | ||||||
| 		if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PostUp); err != nil { | 		if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PostUp); err != nil { | ||||||
| 			return fmt.Errorf("failed to execute post-up hook: %w", err) | 			return fmt.Errorf("failed to execute post-up hook: %w", err) | ||||||
| 		} | 		} | ||||||
| 	} else { | 	} else { | ||||||
| 		if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PostDown); err != nil { | 		if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PostDown); err != nil { | ||||||
| 			return fmt.Errorf("failed to execute post-down hook: %w", err) | 			return fmt.Errorf("failed to execute post-down hook: %w", err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | @ -799,7 +852,7 @@ func (m Manager) getFreshListenPort(ctx context.Context) (port int, err error) { | ||||||
| 
 | 
 | ||||||
| func (m Manager) importInterface( | func (m Manager) importInterface( | ||||||
| 	ctx context.Context, | 	ctx context.Context, | ||||||
| 	backend InterfaceController, | 	backend domain.InterfaceController, | ||||||
| 	in *domain.PhysicalInterface, | 	in *domain.PhysicalInterface, | ||||||
| 	peers []domain.PhysicalPeer, | 	peers []domain.PhysicalPeer, | ||||||
| ) error { | ) error { | ||||||
|  | @ -901,13 +954,9 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (m Manager) deleteInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) error { | func (m Manager) deleteInterfacePeers(ctx context.Context, iface *domain.Interface, allPeers []domain.Peer) error { | ||||||
| 	iface, allPeers, err := m.db.GetInterfaceAndPeers(ctx, id) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	for _, peer := range allPeers { | 	for _, peer := range allPeers { | ||||||
| 		err = m.wg.GetController(*iface).DeletePeer(ctx, id, peer.Identifier) | 		err := m.wg.GetController(*iface).DeletePeer(ctx, iface.Identifier, peer.Identifier) | ||||||
| 		if err != nil && !errors.Is(err, os.ErrNotExist) { | 		if err != nil && !errors.Is(err, os.ErrNotExist) { | ||||||
| 			return fmt.Errorf("wireguard peer deletion failure for %s: %w", peer.Identifier, err) | 			return fmt.Errorf("wireguard peer deletion failure for %s: %w", peer.Identifier, err) | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | @ -388,9 +388,20 @@ func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error | ||||||
| 		return fmt.Errorf("failed to delete peer %s: %w", id, err) | 		return fmt.Errorf("failed to delete peer %s: %w", id, err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to load peers for interface %s: %w", iface.Identifier, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	m.bus.Publish(app.TopicPeerDeleted, *peer) | 	m.bus.Publish(app.TopicPeerDeleted, *peer) | ||||||
| 	// Update routes after peers have changed
 | 	// Update routes after peers have changed
 | ||||||
| 	m.bus.Publish(app.TopicRouteUpdate, "peers updated") | 	m.bus.Publish(app.TopicRouteUpdate, domain.RoutingTableInfo{ | ||||||
|  | 		Interface:  *iface, | ||||||
|  | 		AllowedIps: iface.GetAllowedIPs(peers), | ||||||
|  | 		FwMark:     iface.FirewallMark, | ||||||
|  | 		Table:      iface.GetRoutingTable(), | ||||||
|  | 		TableStr:   iface.RoutingTable, | ||||||
|  | 	}) | ||||||
| 	// Update interface after peers have changed
 | 	// Update interface after peers have changed
 | ||||||
| 	m.bus.Publish(app.TopicPeerInterfaceUpdated, peer.InterfaceIdentifier) | 	m.bus.Publish(app.TopicPeerInterfaceUpdated, peer.InterfaceIdentifier) | ||||||
| 
 | 
 | ||||||
|  | @ -438,20 +449,26 @@ func (m Manager) GetUserPeerStats(ctx context.Context, id domain.UserIdentifier) | ||||||
| // region helper-functions
 | // region helper-functions
 | ||||||
| 
 | 
 | ||||||
| func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error { | func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error { | ||||||
| 	interfaces := make(map[domain.InterfaceIdentifier]struct{}) | 	interfaces := make(map[domain.InterfaceIdentifier]domain.Interface) | ||||||
| 
 | 
 | ||||||
| 	for _, peer := range peers { | 	for _, peer := range peers { | ||||||
|  | 		// get interface from db if it is not yet in the map
 | ||||||
|  | 		if _, ok := interfaces[peer.InterfaceIdentifier]; !ok { | ||||||
| 			iface, err := m.db.GetInterface(ctx, peer.InterfaceIdentifier) | 			iface, err := m.db.GetInterface(ctx, peer.InterfaceIdentifier) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 				return fmt.Errorf("unable to find interface %s: %w", peer.InterfaceIdentifier, err) | 				return fmt.Errorf("unable to find interface %s: %w", peer.InterfaceIdentifier, err) | ||||||
| 			} | 			} | ||||||
|  | 			interfaces[peer.InterfaceIdentifier] = *iface | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		iface := interfaces[peer.InterfaceIdentifier] | ||||||
| 
 | 
 | ||||||
| 		// Always save the peer to the backend, regardless of disabled/expired state
 | 		// Always save the peer to the backend, regardless of disabled/expired state
 | ||||||
| 		// The backend will handle the disabled state appropriately
 | 		// The backend will handle the disabled state appropriately
 | ||||||
| 		err = m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) { | 		err := m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) { | ||||||
| 			peer.CopyCalculatedAttributes(p) | 			peer.CopyCalculatedAttributes(p) | ||||||
| 
 | 
 | ||||||
| 			err := m.wg.GetController(*iface).SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier, | 			err := m.wg.GetController(iface).SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier, | ||||||
| 				func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) { | 				func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) { | ||||||
| 					domain.MergeToPhysicalPeer(pp, peer) | 					domain.MergeToPhysicalPeer(pp, peer) | ||||||
| 					return pp, nil | 					return pp, nil | ||||||
|  | @ -475,13 +492,22 @@ func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error { | ||||||
| 				Peer:   *peer, | 				Peer:   *peer, | ||||||
| 			}, | 			}, | ||||||
| 		}) | 		}) | ||||||
| 
 |  | ||||||
| 		interfaces[peer.InterfaceIdentifier] = struct{}{} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Update routes after peers have changed
 | 	// Update routes after peers have changed
 | ||||||
| 	if len(interfaces) != 0 { | 	for id, iface := range interfaces { | ||||||
| 		m.bus.Publish(app.TopicRouteUpdate, "peers updated") | 		interfacePeers, err := m.db.GetInterfacePeers(ctx, id) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("failed to re-load peers for interface %s: %w", id, err) | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		m.bus.Publish(app.TopicRouteUpdate, domain.RoutingTableInfo{ | ||||||
|  | 			Interface:  iface, | ||||||
|  | 			AllowedIps: iface.GetAllowedIPs(interfacePeers), | ||||||
|  | 			FwMark:     iface.FirewallMark, | ||||||
|  | 			Table:      iface.GetRoutingTable(), | ||||||
|  | 			TableStr:   iface.RoutingTable, | ||||||
|  | 		}) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for iface := range interfaces { | 	for iface := range interfaces { | ||||||
|  |  | ||||||
|  | @ -211,6 +211,10 @@ type OpenIDConnectProvider struct { | ||||||
| 
 | 
 | ||||||
| 	// If LogUserInfo is set to true, the user info retrieved from the OIDC provider will be logged in trace level.
 | 	// If LogUserInfo is set to true, the user info retrieved from the OIDC provider will be logged in trace level.
 | ||||||
| 	LogUserInfo bool `yaml:"log_user_info"` | 	LogUserInfo bool `yaml:"log_user_info"` | ||||||
|  | 
 | ||||||
|  | 	// If LogSensitiveInfo is set to true, sensitive information retrieved from the OIDC provider will be logged in trace level.
 | ||||||
|  | 	// This also includes OAuth tokens! Keep this disabled in production!
 | ||||||
|  | 	LogSensitiveInfo bool `yaml:"log_sensitive_info"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // OAuthProvider contains the configuration for the OAuth provider.
 | // OAuthProvider contains the configuration for the OAuth provider.
 | ||||||
|  | @ -252,6 +256,10 @@ type OAuthProvider struct { | ||||||
| 
 | 
 | ||||||
| 	// If LogUserInfo is set to true, the user info retrieved from the OAuth provider will be logged in trace level.
 | 	// If LogUserInfo is set to true, the user info retrieved from the OAuth provider will be logged in trace level.
 | ||||||
| 	LogUserInfo bool `yaml:"log_user_info"` | 	LogUserInfo bool `yaml:"log_user_info"` | ||||||
|  | 
 | ||||||
|  | 	// If LogSensitiveInfo is set to true, sensitive information retrieved from the OAuth provider will be logged in trace level.
 | ||||||
|  | 	// This also includes OAuth tokens! Keep this disabled in production!
 | ||||||
|  | 	LogSensitiveInfo bool `yaml:"log_sensitive_info"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // WebauthnConfig contains the configuration for the WebAuthn authenticator.
 | // WebauthnConfig contains the configuration for the WebAuthn authenticator.
 | ||||||
|  |  | ||||||
|  | @ -13,6 +13,7 @@ type Backend struct { | ||||||
| 	// Local Backend-specific configuration
 | 	// Local Backend-specific configuration
 | ||||||
| 
 | 
 | ||||||
| 	IgnoredLocalInterfaces []string `yaml:"ignored_local_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0")
 | 	IgnoredLocalInterfaces []string `yaml:"ignored_local_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0")
 | ||||||
|  | 	LocalResolvconfPrefix  string   `yaml:"local_resolvconf_prefix"`  // The prefix to use for interface names when passing them to resolvconf.
 | ||||||
| 
 | 
 | ||||||
| 	// External Backend-specific configuration
 | 	// External Backend-specific configuration
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -134,6 +134,9 @@ func defaultConfig() *Config { | ||||||
| 
 | 
 | ||||||
| 	cfg.Backend = Backend{ | 	cfg.Backend = Backend{ | ||||||
| 		Default: LocalBackendName, // local backend is the default (using wgcrtl)
 | 		Default: LocalBackendName, // local backend is the default (using wgcrtl)
 | ||||||
|  | 		// Most resolconf implementations use "tun." as a prefix for interface names.
 | ||||||
|  | 		// But systemd's implementation uses no prefix, for example.
 | ||||||
|  | 		LocalResolvconfPrefix: "tun.", | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	cfg.Web = WebConfig{ | 	cfg.Web = WebConfig{ | ||||||
|  |  | ||||||
|  | @ -41,4 +41,6 @@ type MailConfig struct { | ||||||
| 	From string `yaml:"from"` | 	From string `yaml:"from"` | ||||||
| 	// LinkOnly specifies whether emails should only contain a link to WireGuard Portal or attach the full configuration
 | 	// LinkOnly specifies whether emails should only contain a link to WireGuard Portal or attach the full configuration
 | ||||||
| 	LinkOnly bool `yaml:"link_only"` | 	LinkOnly bool `yaml:"link_only"` | ||||||
|  | 	// AllowPeerEmail specifies whether emails should be sent to peers which have no valid user account linked, but an email address is set as "user".
 | ||||||
|  | 	AllowPeerEmail bool `yaml:"allow_peer_email"` | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -13,6 +13,7 @@ import ( | ||||||
| 	"golang.org/x/sys/unix" | 	"golang.org/x/sys/unix" | ||||||
| 
 | 
 | ||||||
| 	"github.com/h44z/wg-portal/internal" | 	"github.com/h44z/wg-portal/internal" | ||||||
|  | 	"github.com/h44z/wg-portal/internal/config" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| const ( | const ( | ||||||
|  | @ -132,9 +133,14 @@ func (i *Interface) GetConfigFileName() string { | ||||||
| 	return filename | 	return filename | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // GetAllowedIPs returns the allowed IPs for the interface depending on the interface type and peers.
 | ||||||
|  | // For example, if the interface type is Server, the allowed IPs are the IPs of the peers.
 | ||||||
|  | // If the interface type is Client, the allowed IPs correspond to the AllowedIPsStr of the peers.
 | ||||||
| func (i *Interface) GetAllowedIPs(peers []Peer) []Cidr { | func (i *Interface) GetAllowedIPs(peers []Peer) []Cidr { | ||||||
| 	var allowedCidrs []Cidr | 	var allowedCidrs []Cidr | ||||||
| 
 | 
 | ||||||
|  | 	switch i.Type { | ||||||
|  | 	case InterfaceTypeServer, InterfaceTypeAny: | ||||||
| 		for _, peer := range peers { | 		for _, peer := range peers { | ||||||
| 			for _, ip := range peer.Interface.Addresses { | 			for _, ip := range peer.Interface.Addresses { | ||||||
| 				allowedCidrs = append(allowedCidrs, ip.HostAddr()) | 				allowedCidrs = append(allowedCidrs, ip.HostAddr()) | ||||||
|  | @ -146,6 +152,14 @@ func (i *Interface) GetAllowedIPs(peers []Peer) []Cidr { | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | 	case InterfaceTypeClient: | ||||||
|  | 		for _, peer := range peers { | ||||||
|  | 			allowedIPs, err := CidrsFromString(peer.AllowedIPsStr.GetValue()) | ||||||
|  | 			if err == nil { | ||||||
|  | 				allowedCidrs = append(allowedCidrs, allowedIPs...) | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	return allowedCidrs | 	return allowedCidrs | ||||||
| } | } | ||||||
|  | @ -159,6 +173,7 @@ func (i *Interface) ManageRoutingTable() bool { | ||||||
| //
 | //
 | ||||||
| //	-1 if RoutingTable was set to "off" or an error occurred
 | //	-1 if RoutingTable was set to "off" or an error occurred
 | ||||||
| func (i *Interface) GetRoutingTable() int { | func (i *Interface) GetRoutingTable() int { | ||||||
|  | 
 | ||||||
| 	routingTableStr := strings.ToLower(i.RoutingTable) | 	routingTableStr := strings.ToLower(i.RoutingTable) | ||||||
| 	switch { | 	switch { | ||||||
| 	case routingTableStr == "": | 	case routingTableStr == "": | ||||||
|  | @ -166,6 +181,9 @@ func (i *Interface) GetRoutingTable() int { | ||||||
| 	case routingTableStr == "off": | 	case routingTableStr == "off": | ||||||
| 		return -1 | 		return -1 | ||||||
| 	case strings.HasPrefix(routingTableStr, "0x"): | 	case strings.HasPrefix(routingTableStr, "0x"): | ||||||
|  | 		if i.Backend != config.LocalBackendName { | ||||||
|  | 			return 0 // ignore numeric routing table numbers for non-local controllers
 | ||||||
|  | 		} | ||||||
| 		numberStr := strings.ReplaceAll(routingTableStr, "0x", "") | 		numberStr := strings.ReplaceAll(routingTableStr, "0x", "") | ||||||
| 		routingTable, err := strconv.ParseUint(numberStr, 16, 64) | 		routingTable, err := strconv.ParseUint(numberStr, 16, 64) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
|  | @ -178,6 +196,9 @@ func (i *Interface) GetRoutingTable() int { | ||||||
| 		} | 		} | ||||||
| 		return int(routingTable) | 		return int(routingTable) | ||||||
| 	default: | 	default: | ||||||
|  | 		if i.Backend != config.LocalBackendName { | ||||||
|  | 			return 0 // ignore numeric routing table numbers for non-local controllers
 | ||||||
|  | 		} | ||||||
| 		routingTable, err := strconv.Atoi(routingTableStr) | 		routingTable, err := strconv.Atoi(routingTableStr) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			slog.Error("failed to parse routing table number", "table", routingTableStr, "error", err) | 			slog.Error("failed to parse routing table number", "table", routingTableStr, "error", err) | ||||||
|  | @ -308,12 +329,18 @@ func MergeToPhysicalInterface(pi *PhysicalInterface, i *Interface) { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type RoutingTableInfo struct { | type RoutingTableInfo struct { | ||||||
|  | 	Interface  Interface | ||||||
|  | 	AllowedIps []Cidr | ||||||
| 	FwMark     uint32 | 	FwMark     uint32 | ||||||
| 	Table      int | 	Table      int | ||||||
|  | 	TableStr   string // the routing table number as string (used by mikrotik, linux uses the numeric value)
 | ||||||
|  | 	IsDeleted  bool   // true if the interface was deleted, false otherwise
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (r RoutingTableInfo) String() string { | func (r RoutingTableInfo) String() string { | ||||||
| 	return fmt.Sprintf("%d -> %d", r.FwMark, r.Table) | 	v4, v6 := CidrsPerFamily(r.AllowedIps) | ||||||
|  | 	return fmt.Sprintf("%s: fwmark=%d; table=%d; routes_4=%d; routes_6=%d", r.Interface.Identifier, r.FwMark, r.Table, | ||||||
|  | 		len(v4), len(v6)) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (r RoutingTableInfo) ManagementEnabled() bool { | func (r RoutingTableInfo) ManagementEnabled() bool { | ||||||
|  |  | ||||||
|  | @ -0,0 +1,27 @@ | ||||||
|  | package domain | ||||||
|  | 
 | ||||||
|  | import "context" | ||||||
|  | 
 | ||||||
|  | type InterfaceController interface { | ||||||
|  | 	GetId() InterfaceBackend | ||||||
|  | 	GetInterfaces(_ context.Context) ([]PhysicalInterface, error) | ||||||
|  | 	GetInterface(_ context.Context, id InterfaceIdentifier) (*PhysicalInterface, error) | ||||||
|  | 	GetPeers(_ context.Context, deviceId InterfaceIdentifier) ([]PhysicalPeer, error) | ||||||
|  | 	SaveInterface( | ||||||
|  | 		_ context.Context, | ||||||
|  | 		id InterfaceIdentifier, | ||||||
|  | 		updateFunc func(pi *PhysicalInterface) (*PhysicalInterface, error), | ||||||
|  | 	) error | ||||||
|  | 	DeleteInterface(_ context.Context, id InterfaceIdentifier) error | ||||||
|  | 	SavePeer( | ||||||
|  | 		_ context.Context, | ||||||
|  | 		deviceId InterfaceIdentifier, | ||||||
|  | 		id PeerIdentifier, | ||||||
|  | 		updateFunc func(pp *PhysicalPeer) (*PhysicalPeer, error), | ||||||
|  | 	) error | ||||||
|  | 	DeletePeer(_ context.Context, deviceId InterfaceIdentifier, id PeerIdentifier) error | ||||||
|  | 	PingAddresses( | ||||||
|  | 		ctx context.Context, | ||||||
|  | 		addr string, | ||||||
|  | 	) (*PingerResult, error) | ||||||
|  | } | ||||||
|  | @ -5,6 +5,8 @@ import ( | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"github.com/stretchr/testify/assert" | 	"github.com/stretchr/testify/assert" | ||||||
|  | 
 | ||||||
|  | 	"github.com/h44z/wg-portal/internal/config" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func TestInterface_IsDisabledReturnsTrueWhenDisabled(t *testing.T) { | func TestInterface_IsDisabledReturnsTrueWhenDisabled(t *testing.T) { | ||||||
|  | @ -37,8 +39,9 @@ func TestInterface_GetConfigFileNameReturnsCorrectFileName(t *testing.T) { | ||||||
| 	assert.Equal(t, expected, iface.GetConfigFileName()) | 	assert.Equal(t, expected, iface.GetConfigFileName()) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestInterface_GetAllowedIPsReturnsCorrectCidrs(t *testing.T) { | func TestInterface_GetAllowedIPsReturnsCorrectCidrsServerMode(t *testing.T) { | ||||||
| 	peer1 := Peer{ | 	peer1 := Peer{ | ||||||
|  | 		AllowedIPsStr: ConfigOption[string]{Value: "192.168.2.2/32"}, | ||||||
| 		Interface: PeerInterfaceConfig{ | 		Interface: PeerInterfaceConfig{ | ||||||
| 			Addresses: []Cidr{ | 			Addresses: []Cidr{ | ||||||
| 				{Cidr: "192.168.1.2/32", Addr: "192.168.1.2", NetLength: 32}, | 				{Cidr: "192.168.1.2/32", Addr: "192.168.1.2", NetLength: 32}, | ||||||
|  | @ -46,16 +49,45 @@ func TestInterface_GetAllowedIPsReturnsCorrectCidrs(t *testing.T) { | ||||||
| 		}, | 		}, | ||||||
| 	} | 	} | ||||||
| 	peer2 := Peer{ | 	peer2 := Peer{ | ||||||
|  | 		AllowedIPsStr:      ConfigOption[string]{Value: "10.0.2.2/32"}, | ||||||
|  | 		ExtraAllowedIPsStr: "10.20.2.2/32", | ||||||
| 		Interface: PeerInterfaceConfig{ | 		Interface: PeerInterfaceConfig{ | ||||||
| 			Addresses: []Cidr{ | 			Addresses: []Cidr{ | ||||||
| 				{Cidr: "10.0.0.2/32", Addr: "10.0.0.2", NetLength: 32}, | 				{Cidr: "10.0.0.2/32", Addr: "10.0.0.2", NetLength: 32}, | ||||||
| 			}, | 			}, | ||||||
| 		}, | 		}, | ||||||
| 	} | 	} | ||||||
| 	iface := &Interface{} | 	iface := &Interface{Type: InterfaceTypeServer} | ||||||
| 	expected := []Cidr{ | 	expected := []Cidr{ | ||||||
| 		{Cidr: "192.168.1.2/32", Addr: "192.168.1.2", NetLength: 32}, | 		{Cidr: "192.168.1.2/32", Addr: "192.168.1.2", NetLength: 32}, | ||||||
| 		{Cidr: "10.0.0.2/32", Addr: "10.0.0.2", NetLength: 32}, | 		{Cidr: "10.0.0.2/32", Addr: "10.0.0.2", NetLength: 32}, | ||||||
|  | 		{Cidr: "10.20.2.2/32", Addr: "10.20.2.2", NetLength: 32}, | ||||||
|  | 	} | ||||||
|  | 	assert.Equal(t, expected, iface.GetAllowedIPs([]Peer{peer1, peer2})) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestInterface_GetAllowedIPsReturnsCorrectCidrsClientMode(t *testing.T) { | ||||||
|  | 	peer1 := Peer{ | ||||||
|  | 		AllowedIPsStr: ConfigOption[string]{Value: "192.168.2.2/32"}, | ||||||
|  | 		Interface: PeerInterfaceConfig{ | ||||||
|  | 			Addresses: []Cidr{ | ||||||
|  | 				{Cidr: "192.168.1.2/32", Addr: "192.168.1.2", NetLength: 32}, | ||||||
|  | 			}, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 	peer2 := Peer{ | ||||||
|  | 		AllowedIPsStr:      ConfigOption[string]{Value: "10.0.2.2/32"}, | ||||||
|  | 		ExtraAllowedIPsStr: "10.20.2.2/32", | ||||||
|  | 		Interface: PeerInterfaceConfig{ | ||||||
|  | 			Addresses: []Cidr{ | ||||||
|  | 				{Cidr: "10.0.0.2/32", Addr: "10.0.0.2", NetLength: 32}, | ||||||
|  | 			}, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 	iface := &Interface{Type: InterfaceTypeClient} | ||||||
|  | 	expected := []Cidr{ | ||||||
|  | 		{Cidr: "192.168.2.2/32", Addr: "192.168.2.2", NetLength: 32}, | ||||||
|  | 		{Cidr: "10.0.2.2/32", Addr: "10.0.2.2", NetLength: 32}, | ||||||
| 	} | 	} | ||||||
| 	assert.Equal(t, expected, iface.GetAllowedIPs([]Peer{peer1, peer2})) | 	assert.Equal(t, expected, iface.GetAllowedIPs([]Peer{peer1, peer2})) | ||||||
| } | } | ||||||
|  | @ -66,10 +98,22 @@ func TestInterface_ManageRoutingTableReturnsCorrectValue(t *testing.T) { | ||||||
| 
 | 
 | ||||||
| 	iface.RoutingTable = "100" | 	iface.RoutingTable = "100" | ||||||
| 	assert.True(t, iface.ManageRoutingTable()) | 	assert.True(t, iface.ManageRoutingTable()) | ||||||
|  | 
 | ||||||
|  | 	iface = &Interface{RoutingTable: "off", Backend: config.LocalBackendName} | ||||||
|  | 	assert.False(t, iface.ManageRoutingTable()) | ||||||
|  | 
 | ||||||
|  | 	iface.RoutingTable = "100" | ||||||
|  | 	assert.True(t, iface.ManageRoutingTable()) | ||||||
|  | 
 | ||||||
|  | 	iface = &Interface{RoutingTable: "off", Backend: "mikrotik-xxx"} | ||||||
|  | 	assert.False(t, iface.ManageRoutingTable()) | ||||||
|  | 
 | ||||||
|  | 	iface.RoutingTable = "100" | ||||||
|  | 	assert.True(t, iface.ManageRoutingTable()) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestInterface_GetRoutingTableReturnsCorrectValue(t *testing.T) { | func TestInterface_GetRoutingTableReturnsCorrectValue(t *testing.T) { | ||||||
| 	iface := &Interface{RoutingTable: ""} | 	iface := &Interface{RoutingTable: "", Backend: config.LocalBackendName} | ||||||
| 	assert.Equal(t, 0, iface.GetRoutingTable()) | 	assert.Equal(t, 0, iface.GetRoutingTable()) | ||||||
| 
 | 
 | ||||||
| 	iface.RoutingTable = "off" | 	iface.RoutingTable = "off" | ||||||
|  | @ -81,3 +125,17 @@ func TestInterface_GetRoutingTableReturnsCorrectValue(t *testing.T) { | ||||||
| 	iface.RoutingTable = "200" | 	iface.RoutingTable = "200" | ||||||
| 	assert.Equal(t, 200, iface.GetRoutingTable()) | 	assert.Equal(t, 200, iface.GetRoutingTable()) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func TestInterface_GetRoutingTableNonLocal(t *testing.T) { | ||||||
|  | 	iface := &Interface{RoutingTable: "off", Backend: "something different"} | ||||||
|  | 	assert.Equal(t, -1, iface.GetRoutingTable()) | ||||||
|  | 
 | ||||||
|  | 	iface.RoutingTable = "0" | ||||||
|  | 	assert.Equal(t, 0, iface.GetRoutingTable()) | ||||||
|  | 
 | ||||||
|  | 	iface.RoutingTable = "100" | ||||||
|  | 	assert.Equal(t, 0, iface.GetRoutingTable()) | ||||||
|  | 
 | ||||||
|  | 	iface.RoutingTable = "abc" | ||||||
|  | 	assert.Equal(t, 0, iface.GetRoutingTable()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -26,6 +26,10 @@ func (c Cidr) IsValid() bool { | ||||||
| 	return c.Prefix().IsValid() | 	return c.Prefix().IsValid() | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (c Cidr) EqualPrefix(other Cidr) bool { | ||||||
|  | 	return c.Addr == other.Addr && c.NetLength == other.NetLength | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func CidrFromString(str string) (Cidr, error) { | func CidrFromString(str string) (Cidr, error) { | ||||||
| 	prefix, err := netip.ParsePrefix(strings.TrimSpace(str)) | 	prefix, err := netip.ParsePrefix(strings.TrimSpace(str)) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | @ -199,3 +203,26 @@ func (c Cidr) Contains(other Cidr) bool { | ||||||
| 
 | 
 | ||||||
| 	return subnet.Contains(otherIP) | 	return subnet.Contains(otherIP) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // ContainsDefaultRoute returns true if the given CIDRs contain a default route.
 | ||||||
|  | func ContainsDefaultRoute(cidrs []Cidr) bool { | ||||||
|  | 	for _, allowedIP := range cidrs { | ||||||
|  | 		if allowedIP.Prefix().Bits() == 0 { | ||||||
|  | 			return true | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return false | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // CidrsPerFamily returns a slice of CIDRs, one for each family (IPv4 and IPv6).
 | ||||||
|  | func CidrsPerFamily(cidrs []Cidr) (ipv4, ipv6 []Cidr) { | ||||||
|  | 	for _, cidr := range cidrs { | ||||||
|  | 		if cidr.IsV4() { | ||||||
|  | 			ipv4 = append(ipv4, cidr) | ||||||
|  | 		} else { | ||||||
|  | 			ipv6 = append(ipv6, cidr) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -308,22 +308,33 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer { | ||||||
| 
 | 
 | ||||||
| func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) { | func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) { | ||||||
| 	pp.Identifier = p.Identifier | 	pp.Identifier = p.Identifier | ||||||
| 	pp.Endpoint = p.Endpoint.GetValue() | 	pp.PresharedKey = p.PresharedKey | ||||||
| 	if p.Interface.Type == InterfaceTypeServer { | 	pp.PublicKey = p.Interface.PublicKey | ||||||
|  | 
 | ||||||
|  | 	switch p.Interface.Type { | ||||||
|  | 	case InterfaceTypeClient: // this means that the corresponding interface in wgportal is a server interface
 | ||||||
|  | 		allowedIPs := make([]Cidr, len(p.Interface.Addresses)) | ||||||
|  | 		for i, ip := range p.Interface.Addresses { | ||||||
|  | 			allowedIPs[i] = ip.HostAddr() // add the peer's host address to the allowed IPs
 | ||||||
|  | 		} | ||||||
|  | 		extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr) | ||||||
|  | 		pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...) | ||||||
|  | 	case InterfaceTypeServer: // this means that the corresponding interface in wgportal is a client interface
 | ||||||
| 		allowedIPs, _ := CidrsFromString(p.AllowedIPsStr.GetValue()) | 		allowedIPs, _ := CidrsFromString(p.AllowedIPsStr.GetValue()) | ||||||
| 		extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr) | 		extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr) | ||||||
| 		pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...) | 		pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...) | ||||||
| 	} else { | 		pp.Endpoint = p.Endpoint.GetValue() | ||||||
|  | 		pp.PersistentKeepalive = p.PersistentKeepalive.GetValue() | ||||||
|  | 	case InterfaceTypeAny: // this means that the corresponding interface in wgportal has no specific type
 | ||||||
| 		allowedIPs := make([]Cidr, len(p.Interface.Addresses)) | 		allowedIPs := make([]Cidr, len(p.Interface.Addresses)) | ||||||
| 		for i, ip := range p.Interface.Addresses { | 		for i, ip := range p.Interface.Addresses { | ||||||
| 			allowedIPs[i] = ip.HostAddr() | 			allowedIPs[i] = ip.HostAddr() // add the peer's host address to the allowed IPs
 | ||||||
| 		} | 		} | ||||||
| 		extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr) | 		extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr) | ||||||
| 		pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...) | 		pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...) | ||||||
| 	} | 		pp.Endpoint = p.Endpoint.GetValue() | ||||||
| 	pp.PresharedKey = p.PresharedKey |  | ||||||
| 	pp.PublicKey = p.Interface.PublicKey |  | ||||||
| 		pp.PersistentKeepalive = p.PersistentKeepalive.GetValue() | 		pp.PersistentKeepalive = p.PersistentKeepalive.GetValue() | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	switch pp.ImportSource { | 	switch pp.ImportSource { | ||||||
| 	case ControllerTypeMikrotik: | 	case ControllerTypeMikrotik: | ||||||
|  |  | ||||||
|  | @ -267,6 +267,7 @@ func parseHttpResponse[T any](resp *http.Response, err error) MikrotikApiRespons | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	defer func(Body io.ReadCloser) { | 	defer func(Body io.ReadCloser) { | ||||||
|  | 		_, _ = io.Copy(io.Discard, Body) // ensure to empty the body
 | ||||||
| 		err := Body.Close() | 		err := Body.Close() | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			slog.Error("failed to close response body", "error", err) | 			slog.Error("failed to close response body", "error", err) | ||||||
|  |  | ||||||
|  | @ -6,8 +6,12 @@ repo_name: h44z/wg-portal | ||||||
| repo_url: https://github.com/h44z/wg-portal | repo_url: https://github.com/h44z/wg-portal | ||||||
| copyright: Copyright © 2023-2025 WireGuard Portal Project | copyright: Copyright © 2023-2025 WireGuard Portal Project | ||||||
| 
 | 
 | ||||||
|  | extra_javascript: | ||||||
|  |   - javascript/img-comparison-slider.js | ||||||
|  | 
 | ||||||
| extra_css: | extra_css: | ||||||
|   - stylesheets/extra.css |   - stylesheets/extra.css | ||||||
|  |   - stylesheets/img-comparison-slider.css | ||||||
| 
 | 
 | ||||||
| theme: | theme: | ||||||
|   name: material |   name: material | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue