Compare commits

...

8 Commits

Author SHA1 Message Date
Christoph Haas
e65cab4857 add funding info, prepare release v2.1 2025-10-04 14:14:08 +02:00
Christoph Haas
75ec234a72 add dark/light image to doc 2025-10-04 13:42:52 +02:00
Christoph Haas
4729bccdd3 add dark/light image to doc 2025-09-17 22:55:17 +02:00
Christoph Haas
afb38b685c improve logging of LDAP login process (#529)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-09-17 22:33:54 +02:00
h44z
7cd7d13dc7 fix peer creation if custom public key is set (#523) (#528)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-09-15 22:54:34 +02:00
dependabot[bot]
d945e313b2 chore(deps): bump github.com/go-webauthn/webauthn from 0.13.4 to 0.14.0 (#526)
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.13.4 to 0.14.0.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.13.4...v0.14.0)

---
updated-dependencies:
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-15 22:50:10 +02:00
dependabot[bot]
c5fe82ab11 chore(deps): bump gorm.io/gorm from 1.30.5 to 1.31.0 in the gorm group (#527)
Bumps the gorm group with 1 update: [gorm.io/gorm](https://github.com/go-gorm/gorm).


Updates `gorm.io/gorm` from 1.30.5 to 1.31.0
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.30.5...v1.31.0)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-version: 1.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gorm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-15 22:48:44 +02:00
h44z
765fb09770 Mikrotik improvements (#521)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* allow to specify ignored interfaces (#514)

* only set endpoint info for "responder" peers (#516)
2025-09-09 21:43:16 +02:00
23 changed files with 457 additions and 84 deletions

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

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View File

@@ -11,6 +11,24 @@ core:
create_default_peer: true create_default_peer: true
self_provisioning_allowed: true self_provisioning_allowed: true
backend:
# default backend decides where new interfaces are created
default: mikrotik
mikrotik:
- id: mikrotik # unique id, not "local"
display_name: RouterOS RB5009 # optional nice name
api_url: https://10.10.10.10/rest
api_user: wgportal
api_password: a-super-secret-password
api_verify_tls: false # set to false only if using self-signed during testing
api_timeout: 30s # maximum request duration
concurrency: 5 # limit parallel REST calls to device
debug: false # verbose logging for this backend
ignored_interfaces: # ignore these interfaces during import
- wgTest1
- wgTest2
web: web:
site_title: My WireGuard Server site_title: My WireGuard Server
site_company_name: My Company site_company_name: My Company
@@ -195,3 +213,5 @@ auth:
registration_enabled: true registration_enabled: true
log_user_info: true log_user_info: true
``` ```
For more information, check out the usage documentation (e.g. [General Configuration](../usage/general.md) or [Backends Configuration](../usage/backends.md)).

View File

@@ -184,6 +184,11 @@ The current MikroTik backend is in **BETA** and may not support all features.
- **Description:** The default backend to use for managing WireGuard interfaces. - **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.
### `ignored_local_interfaces`
- **Default:** *(empty)*
- **Description:** A list of interface names to exclude when enumerating local interfaces.
This is useful if you want to prevent certain interfaces from being imported from the local system.
### Mikrotik ### Mikrotik
The `mikrotik` array contains a list of MikroTik backend definitions. Each entry describes how to connect to a MikroTik RouterOS instance that hosts WireGuard interfaces. The `mikrotik` array contains a list of MikroTik backend definitions. Each entry describes how to connect to a MikroTik RouterOS instance that hosts WireGuard interfaces.
@@ -225,6 +230,11 @@ Below are the properties for each entry inside `backend.mikrotik`:
- **Default:** `5` - **Default:** `5`
- **Description:** Maximum number of concurrent API requests the backend will issue when enumerating interfaces and their details. If `0` or negative, a sane default of `5` is used. - **Description:** Maximum number of concurrent API requests the backend will issue when enumerating interfaces and their details. If `0` or negative, a sane default of `5` is used.
#### `ignored_interfaces`
- **Default:** *(empty)*
- **Description:** A list of interface names to exclude during interface enumeration.
This is useful if you want to prevent specific interfaces from being imported from the MikroTik device.
#### `debug` #### `debug`
- **Default:** `false` - **Default:** `false`
- **Description:** Enable verbose debug logging for the MikroTik backend. - **Description:** Enable verbose debug logging for the MikroTik backend.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -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 -->
@@ -326,11 +379,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>

6
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-ldap/ldap/v3 v3.4.11
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.27.0
github.com/go-webauthn/webauthn v0.13.4 github.com/go-webauthn/webauthn v0.14.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/prometheus-community/pro-bing v0.7.0 github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
@@ -29,7 +29,7 @@ require (
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlserver v1.6.1 gorm.io/driver/sqlserver v1.6.1
gorm.io/gorm v1.30.5 gorm.io/gorm v1.31.0
) )
require ( require (
@@ -64,7 +64,7 @@ require (
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
github.com/go-test/deep v1.1.1 // indirect github.com/go-test/deep v1.1.1 // indirect
github.com/go-webauthn/x v0.1.24 // indirect github.com/go-webauthn/x v0.1.25 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
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

14
go.sum
View File

@@ -106,10 +106,10 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-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=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ= github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI= github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
github.com/go-webauthn/x v0.1.24 h1:6LaWf2zzWqbyKT8IyQkhje1/1KCGhlEkMz4V1tDnt/A= github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
github.com/go-webauthn/x v0.1.24/go.mod h1:2o5XKJ+X1AKqYKGgHdKflGnoQFQZ6flJ2IFCBKSbSOw= github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
@@ -255,6 +255,8 @@ github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 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/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
@@ -397,8 +399,8 @@ gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXD
gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY= gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY=
gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.30.5 h1:dvEfYwxL+i+xgCNSGGBT1lDjCzfELK8fHZxL3Ee9X0s= gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.30.5/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s= modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s=
modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=

View File

@@ -3,14 +3,13 @@ package wgcontroller
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"log/slog"
"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" "github.com/h44z/wg-portal/internal/lowlevel"
@@ -678,11 +677,15 @@ func (c *MikrotikController) updatePeer(
extras := pp.GetExtras().(domain.MikrotikPeerExtras) extras := pp.GetExtras().(domain.MikrotikPeerExtras)
peerId := extras.Id peerId := extras.Id
endpoint := pp.Endpoint endpoint := "" // by default, we have no endpoint (the peer does not initiate a connection)
endpointPort := "51820" // default port if not set endpointPort := "0" // by default, we have no endpoint port (the peer does not initiate a connection)
if s := strings.Split(endpoint, ":"); len(s) == 2 { if !extras.IsResponder { // if the peer is not only a responder, it needs the endpoint to initiate a connection
endpoint = s[0] endpoint = pp.Endpoint
endpointPort = s[1] endpointPort = "51820" // default port if not set
if s := strings.Split(endpoint, ":"); len(s) == 2 {
endpoint = s[0]
endpointPort = s[1]
}
} }
allowedAddressStr := domain.CidrsToString(pp.AllowedIPs) allowedAddressStr := domain.CidrsToString(pp.AllowedIPs)

View File

@@ -364,6 +364,7 @@ func (a *Authenticator) passwordAuthentication(
} }
ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo) ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo)
if err != nil { if err != nil {
slog.Error("failed to parse ldap user info", "identifier", identifier, "error", err)
continue continue
} }
@@ -376,10 +377,13 @@ func (a *Authenticator) passwordAuthentication(
} }
if userSource == "" { if userSource == "" {
slog.Warn("no user source found for user", "identifier", identifier, "ldapProviderCount", a.ldapAuthenticators)
return nil, errors.New("user not found") return nil, errors.New("user not found")
} }
if userSource == domain.UserSourceLdap && ldapProvider == nil { if userSource == domain.UserSourceLdap && ldapProvider == nil {
slog.Warn("no ldap provider found for user",
"identifier", identifier, "ldapProviderCount", a.ldapAuthenticators)
return nil, errors.New("ldap provider not found") return nil, errors.New("ldap provider not found")
} }

View File

@@ -82,8 +82,9 @@ func (c *ControllerManager) registerLocalController() error {
c.controllers[config.LocalBackendName] = backendInstance{ c.controllers[config.LocalBackendName] = backendInstance{
Config: config.BackendBase{ Config: config.BackendBase{
Id: config.LocalBackendName, Id: config.LocalBackendName,
DisplayName: "Local WireGuard Controller", DisplayName: "Local WireGuard Controller",
IgnoredInterfaces: c.cfg.Backend.IgnoredLocalInterfaces,
}, },
Implementation: localController, Implementation: localController,
} }
@@ -118,17 +119,17 @@ func (c *ControllerManager) logRegisteredControllers() {
} }
func (c *ControllerManager) GetControllerByName(backend domain.InterfaceBackend) InterfaceController { func (c *ControllerManager) GetControllerByName(backend domain.InterfaceBackend) InterfaceController {
return c.getController(backend, "") return c.getController(backend, "").Implementation
} }
func (c *ControllerManager) GetController(iface domain.Interface) InterfaceController { func (c *ControllerManager) GetController(iface domain.Interface) InterfaceController {
return c.getController(iface.Backend, iface.Identifier) return c.getController(iface.Backend, iface.Identifier).Implementation
} }
func (c *ControllerManager) getController( func (c *ControllerManager) getController(
backend domain.InterfaceBackend, backend domain.InterfaceBackend,
ifaceId domain.InterfaceIdentifier, ifaceId domain.InterfaceIdentifier,
) InterfaceController { ) backendInstance {
if backend == "" { if backend == "" {
// If no backend is specified, use the local controller. // If no backend is specified, use the local controller.
// This might be the case for interfaces created in previous WireGuard Portal versions. // This might be the case for interfaces created in previous WireGuard Portal versions.
@@ -145,13 +146,13 @@ func (c *ControllerManager) getController(
slog.Warn("controller for backend not found, using local controller", slog.Warn("controller for backend not found, using local controller",
"backend", backend, "interface", ifaceId) "backend", backend, "interface", ifaceId)
} }
return controller.Implementation return controller
} }
func (c *ControllerManager) GetAllControllers() []InterfaceController { func (c *ControllerManager) GetAllControllers() []backendInstance {
var backendInstances = make([]InterfaceController, 0, len(c.controllers)) var backendInstances = make([]backendInstance, 0, len(c.controllers))
for instance := range maps.Values(c.controllers) { for instance := range maps.Values(c.controllers) {
backendInstances = append(backendInstances, instance.Implementation) backendInstances = append(backendInstances, instance)
} }
return backendInstances return backendInstances
} }

View File

@@ -15,26 +15,6 @@ import (
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
// GetImportableInterfaces returns all physical interfaces that are available on the system.
// This function also returns interfaces that are already available in the database.
func (m Manager) GetImportableInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
}
var allPhysicalInterfaces []domain.PhysicalInterface
for _, wgBackend := range m.wg.GetAllControllers() {
physicalInterfaces, err := wgBackend.GetInterfaces(ctx)
if err != nil {
return nil, err
}
allPhysicalInterfaces = append(allPhysicalInterfaces, physicalInterfaces...)
}
return allPhysicalInterfaces, nil
}
// GetInterfaceAndPeers returns the interface and all peers for the given interface identifier. // GetInterfaceAndPeers returns the interface and all peers for the given interface identifier.
func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) ( func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (
*domain.Interface, *domain.Interface,
@@ -110,52 +90,62 @@ func (m Manager) GetUserInterfaces(ctx context.Context, _ domain.UserIdentifier)
} }
// ImportNewInterfaces imports all new physical interfaces that are available on the system. // ImportNewInterfaces imports all new physical interfaces that are available on the system.
// If a filter is set, only interfaces that match the filter will be imported.
func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.InterfaceIdentifier) (int, error) { func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.InterfaceIdentifier) (int, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil { if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return 0, err return 0, err
} }
var existingInterfaceIds []domain.InterfaceIdentifier
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return 0, err
}
for _, existingInterface := range existingInterfaces {
existingInterfaceIds = append(existingInterfaceIds, existingInterface.Identifier)
}
imported := 0 imported := 0
for _, wgBackend := range m.wg.GetAllControllers() { for _, wgBackend := range m.wg.GetAllControllers() {
physicalInterfaces, err := wgBackend.GetInterfaces(ctx) physicalInterfaces, err := wgBackend.Implementation.GetInterfaces(ctx)
if err != nil { if err != nil {
return 0, err return 0, err
} }
// if no filter is given, exclude already existing interfaces
var excludedInterfaces []domain.InterfaceIdentifier
if len(filter) == 0 {
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return 0, err
}
for _, existingInterface := range existingInterfaces {
excludedInterfaces = append(excludedInterfaces, existingInterface.Identifier)
}
}
for _, physicalInterface := range physicalInterfaces { for _, physicalInterface := range physicalInterfaces {
if slices.Contains(excludedInterfaces, physicalInterface.Identifier) { if slices.Contains(wgBackend.Config.IgnoredInterfaces, string(physicalInterface.Identifier)) {
slog.Info("ignoring interface due to backend filter restrictions",
"interface", physicalInterface.Identifier, "filter", wgBackend.Config.IgnoredInterfaces,
"backend", wgBackend.Config.Id)
continue // skip ignored interfaces
}
if slices.Contains(existingInterfaceIds, physicalInterface.Identifier) {
continue // skip interfaces that already exist
}
if len(filter) > 0 && !slices.Contains(filter, physicalInterface.Identifier) {
slog.Info("ignoring interface due to filter restrictions",
"interface", physicalInterface.Identifier, "filter", wgBackend.Config.IgnoredInterfaces,
"backend", wgBackend.Config.Id)
continue continue
} }
if len(filter) != 0 && !slices.Contains(filter, physicalInterface.Identifier) { slog.Info("importing new interface",
continue "interface", physicalInterface.Identifier, "backend", wgBackend.Config.Id)
}
slog.Info("importing new interface", "interface", physicalInterface.Identifier) physicalPeers, err := wgBackend.Implementation.GetPeers(ctx, physicalInterface.Identifier)
physicalPeers, err := wgBackend.GetPeers(ctx, physicalInterface.Identifier)
if err != nil { if err != nil {
return 0, err return 0, err
} }
err = m.importInterface(ctx, wgBackend, &physicalInterface, physicalPeers) err = m.importInterface(ctx, wgBackend.Implementation, &physicalInterface, physicalPeers)
if err != nil { if err != nil {
return 0, fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err) return 0, fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err)
} }
slog.Info("imported new interface", "interface", physicalInterface.Identifier, "peers", len(physicalPeers)) slog.Info("imported new interface",
"interface", physicalInterface.Identifier, "peers", len(physicalPeers), "backend", wgBackend.Config.Id)
imported++ imported++
} }
} }
@@ -221,9 +211,11 @@ func (m Manager) RestoreInterfaceState(
return fmt.Errorf("failed to load peers for %s: %w", iface.Identifier, err) return fmt.Errorf("failed to load peers for %s: %w", iface.Identifier, err)
} }
_, err = m.wg.GetController(iface).GetInterface(ctx, iface.Identifier) controller := m.wg.GetController(iface)
_, err = controller.GetInterface(ctx, iface.Identifier)
if err != nil && !iface.IsDisabled() { if err != nil && !iface.IsDisabled() {
slog.Debug("creating missing interface", "interface", iface.Identifier) slog.Debug("creating missing interface", "interface", iface.Identifier, "backend", controller.GetId())
// temporarily disable interface in database so that the current state is reflected correctly // temporarily disable interface in database so that the current state is reflected correctly
_ = m.db.SaveInterface(ctx, iface.Identifier, _ = m.db.SaveInterface(ctx, iface.Identifier,
@@ -250,7 +242,8 @@ func (m Manager) RestoreInterfaceState(
return fmt.Errorf("failed to create physical interface %s: %w", iface.Identifier, err) return fmt.Errorf("failed to create physical interface %s: %w", iface.Identifier, err)
} }
} else { } else {
slog.Debug("restoring interface state", "interface", iface.Identifier, "disabled", iface.IsDisabled()) slog.Debug("restoring interface state",
"interface", iface.Identifier, "disabled", iface.IsDisabled(), "backend", controller.GetId())
// try to move interface to stored state // try to move interface to stored state
_, err = m.saveInterface(ctx, &iface) _, err = m.saveInterface(ctx, &iface)
@@ -278,13 +271,13 @@ func (m Manager) RestoreInterfaceState(
for _, peer := range peers { for _, peer := range peers {
switch { switch {
case iface.IsDisabled() && iface.Backend == config.LocalBackendName: // if interface is disabled, delete all peers case iface.IsDisabled() && iface.Backend == config.LocalBackendName: // if interface is disabled, delete all peers
if err := m.wg.GetController(iface).DeletePeer(ctx, iface.Identifier, if err := controller.DeletePeer(ctx, iface.Identifier,
peer.Identifier); err != nil { peer.Identifier); err != nil {
return fmt.Errorf("failed to remove peer %s for disabled interface %s: %w", return fmt.Errorf("failed to remove peer %s for disabled interface %s: %w",
peer.Identifier, iface.Identifier, err) peer.Identifier, iface.Identifier, err)
} }
default: // update peer default: // update peer
err := m.wg.GetController(iface).SavePeer(ctx, iface.Identifier, peer.Identifier, err := controller.SavePeer(ctx, iface.Identifier, peer.Identifier,
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) { func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
domain.MergeToPhysicalPeer(pp, &peer) domain.MergeToPhysicalPeer(pp, &peer)
return pp, nil return pp, nil
@@ -297,7 +290,7 @@ func (m Manager) RestoreInterfaceState(
} }
// remove non-wgportal peers // remove non-wgportal peers
physicalPeers, _ := m.wg.GetController(iface).GetPeers(ctx, iface.Identifier) physicalPeers, _ := controller.GetPeers(ctx, iface.Identifier)
for _, physicalPeer := range physicalPeers { for _, physicalPeer := range physicalPeers {
isWgPortalPeer := false isWgPortalPeer := false
for _, peer := range peers { for _, peer := range peers {
@@ -307,7 +300,7 @@ func (m Manager) RestoreInterfaceState(
} }
} }
if !isWgPortalPeer { if !isWgPortalPeer {
err := m.wg.GetController(iface).DeletePeer(ctx, iface.Identifier, err := controller.DeletePeer(ctx, iface.Identifier,
domain.PeerIdentifier(physicalPeer.PublicKey)) domain.PeerIdentifier(physicalPeer.PublicKey))
if err != nil { if err != nil {
return fmt.Errorf("failed to remove non-wgportal peer %s from interface %s: %w", return fmt.Errorf("failed to remove non-wgportal peer %s from interface %s: %w",
@@ -551,6 +544,30 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
return nil, fmt.Errorf("failed to save interface: %w", err) return nil, fmt.Errorf("failed to save interface: %w", err)
} }
// update the interface type of peers in db
peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
if err != nil {
return nil, fmt.Errorf("failed to load peers for interface %s: %w", iface.Identifier, err)
}
for _, peer := range peers {
err := m.db.SavePeer(ctx, peer.Identifier, func(_ *domain.Peer) (*domain.Peer, error) {
switch iface.Type {
case domain.InterfaceTypeAny:
peer.Interface.Type = domain.InterfaceTypeAny
case domain.InterfaceTypeClient:
peer.Interface.Type = domain.InterfaceTypeServer
case domain.InterfaceTypeServer:
peer.Interface.Type = domain.InterfaceTypeClient
}
return &peer, nil
})
if err != nil {
return nil, fmt.Errorf("failed to update peer %s for interface %s: %w", peer.Identifier,
iface.Identifier, err)
}
}
if iface.IsDisabled() { if iface.IsDisabled() {
physicalInterface, _ := m.wg.GetController(*iface).GetInterface(ctx, iface.Identifier) physicalInterface, _ := m.wg.GetController(*iface).GetInterface(ctx, iface.Identifier)
fwMark := iface.FirewallMark fwMark := iface.FirewallMark

View File

@@ -188,6 +188,8 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
sessionUser := domain.GetUserInfo(ctx) sessionUser := domain.GetUserInfo(ctx)
peer.Identifier = domain.PeerIdentifier(peer.Interface.PublicKey) // ensure that identifier corresponds to the public key
// Enforce peer limit for non-admin users if LimitAdditionalUserPeers is set // Enforce peer limit for non-admin users if LimitAdditionalUserPeers is set
if m.cfg.Core.SelfProvisioningAllowed && !sessionUser.IsAdmin && m.cfg.Advanced.LimitAdditionalUserPeers > 0 { if m.cfg.Core.SelfProvisioningAllowed && !sessionUser.IsAdmin && m.cfg.Advanced.LimitAdditionalUserPeers > 0 {
peers, err := m.db.GetUserPeers(ctx, peer.UserIdentifier) peers, err := m.db.GetUserPeers(ctx, peer.UserIdentifier)

View File

@@ -0,0 +1,194 @@
package wireguard
import (
"context"
"testing"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
// --- Test mocks ---
type mockBus struct{}
func (f *mockBus) Publish(topic string, args ...any) {}
func (f *mockBus) Subscribe(topic string, fn interface{}) error { return nil }
type mockController struct{}
func (f *mockController) GetId() domain.InterfaceBackend { return "local" }
func (f *mockController) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) {
return nil, nil
}
func (f *mockController) GetInterface(_ context.Context, id domain.InterfaceIdentifier) (
*domain.PhysicalInterface,
error,
) {
return &domain.PhysicalInterface{Identifier: id}, nil
}
func (f *mockController) GetPeers(_ context.Context, _ domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) {
return nil, nil
}
func (f *mockController) SaveInterface(
_ context.Context,
_ domain.InterfaceIdentifier,
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
) error {
_, _ = updateFunc(&domain.PhysicalInterface{})
return nil
}
func (f *mockController) DeleteInterface(_ context.Context, _ domain.InterfaceIdentifier) error {
return nil
}
func (f *mockController) SavePeer(
_ context.Context,
_ domain.InterfaceIdentifier,
_ domain.PeerIdentifier,
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
) error {
_, _ = updateFunc(&domain.PhysicalPeer{})
return nil
}
func (f *mockController) DeletePeer(_ context.Context, _ domain.InterfaceIdentifier, _ domain.PeerIdentifier) error {
return nil
}
func (f *mockController) PingAddresses(_ context.Context, _ string) (*domain.PingerResult, error) {
return nil, nil
}
type mockDB struct {
savedPeers map[domain.PeerIdentifier]*domain.Peer
iface *domain.Interface
}
func (f *mockDB) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
if f.iface != nil && f.iface.Identifier == id {
return f.iface, nil
}
return &domain.Interface{Identifier: id}, nil
}
func (f *mockDB) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (
*domain.Interface,
[]domain.Peer,
error,
) {
return f.iface, nil, nil
}
func (f *mockDB) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error) {
return nil, nil
}
func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) { return nil, nil }
func (f *mockDB) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error) {
return nil, nil
}
func (f *mockDB) SaveInterface(
ctx context.Context,
id domain.InterfaceIdentifier,
updateFunc func(in *domain.Interface) (*domain.Interface, error),
) error {
if f.iface == nil {
f.iface = &domain.Interface{Identifier: id}
}
var err error
f.iface, err = updateFunc(f.iface)
return err
}
func (f *mockDB) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error {
return nil
}
func (f *mockDB) GetInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.Peer, error) {
return nil, nil
}
func (f *mockDB) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) {
return nil, nil
}
func (f *mockDB) SavePeer(
ctx context.Context,
id domain.PeerIdentifier,
updateFunc func(in *domain.Peer) (*domain.Peer, error),
) error {
if f.savedPeers == nil {
f.savedPeers = make(map[domain.PeerIdentifier]*domain.Peer)
}
existing := f.savedPeers[id]
if existing == nil {
existing = &domain.Peer{Identifier: id}
}
updated, err := updateFunc(existing)
if err != nil {
return err
}
f.savedPeers[updated.Identifier] = updated
return nil
}
func (f *mockDB) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error { return nil }
func (f *mockDB) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) {
return nil, domain.ErrNotFound
}
func (f *mockDB) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr) (
map[domain.Cidr][]domain.Cidr,
error,
) {
return map[domain.Cidr][]domain.Cidr{}, nil
}
// --- Test ---
func TestCreatePeer_SetsIdentifier_FromPublicKey(t *testing.T) {
// Arrange
cfg := &config.Config{}
cfg.Core.SelfProvisioningAllowed = true
cfg.Core.EditableKeys = true
cfg.Advanced.LimitAdditionalUserPeers = 0
bus := &mockBus{}
// Prepare a controller manager with our mock controller
ctrlMgr := &ControllerManager{
controllers: map[domain.InterfaceBackend]backendInstance{
config.LocalBackendName: {Implementation: &mockController{}},
},
}
db := &mockDB{iface: &domain.Interface{Identifier: "wg0", Type: domain.InterfaceTypeServer}}
m := Manager{
cfg: cfg,
bus: bus,
db: db,
wg: ctrlMgr,
}
userId := domain.UserIdentifier("user@example.com")
ctx := domain.SetUserInfo(context.Background(), &domain.ContextUserInfo{Id: userId, IsAdmin: false})
pubKey := "TEST_PUBLIC_KEY_ABC123"
input := &domain.Peer{
Identifier: "should_be_overwritten",
UserIdentifier: userId,
InterfaceIdentifier: domain.InterfaceIdentifier("wg0"),
Interface: domain.PeerInterfaceConfig{
KeyPair: domain.KeyPair{PublicKey: pubKey},
},
}
// Act
out, err := m.CreatePeer(ctx, input)
// Assert
if err != nil {
t.Fatalf("CreatePeer returned error: %v", err)
}
expectedId := domain.PeerIdentifier(pubKey)
if out.Identifier != expectedId {
t.Fatalf("expected Identifier to be set from public key %q, got %q", expectedId, out.Identifier)
}
// Ensure the saved peer in DB also has the expected identifier
if db.savedPeers[expectedId] == nil {
t.Fatalf("expected peer with identifier %q to be saved in DB", expectedId)
}
}

View File

@@ -10,6 +10,12 @@ const LocalBackendName = "local"
type Backend struct { type Backend struct {
Default string `yaml:"default"` // The default backend to use (defaults to the internal backend) Default string `yaml:"default"` // The default backend to use (defaults to the internal backend)
// Local Backend-specific configuration
IgnoredLocalInterfaces []string `yaml:"ignored_local_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0")
// External Backend-specific configuration
Mikrotik []BackendMikrotik `yaml:"mikrotik"` Mikrotik []BackendMikrotik `yaml:"mikrotik"`
} }
@@ -42,6 +48,8 @@ func (b *Backend) Validate() error {
type BackendBase struct { type BackendBase struct {
Id string `yaml:"id"` // A unique id for the backend Id string `yaml:"id"` // A unique id for the backend
DisplayName string `yaml:"display_name"` // A display name for the backend DisplayName string `yaml:"display_name"` // A display name for the backend
IgnoredInterfaces []string `yaml:"ignored_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0")
} }
// GetDisplayName returns the display name of the backend. // GetDisplayName returns the display name of the backend.

View File

@@ -328,7 +328,7 @@ func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
Id: "", Id: "",
Name: p.DisplayName, Name: p.DisplayName,
Comment: p.Notes, Comment: p.Notes,
IsResponder: false, IsResponder: p.Interface.Type == InterfaceTypeClient,
Disabled: p.IsDisabled(), Disabled: p.IsDisabled(),
ClientEndpoint: p.Endpoint.GetValue(), ClientEndpoint: p.Endpoint.GetValue(),
ClientAddress: CidrsToString(p.Interface.Addresses), ClientAddress: CidrsToString(p.Interface.Addresses),

View File

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