Compare commits

..

1 Commits

Author SHA1 Message Date
Christoph Haas
d4968dcd6c bulk actions for peers and users (#492) 2026-01-04 23:53:34 +01:00
77 changed files with 892 additions and 1881 deletions

View File

@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
@@ -35,7 +35,7 @@ jobs:
# ct lint requires Python 3.x to run following packages:
# - yamale (https://github.com/23andMe/Yamale)
# - yamllint (https://github.com/adrienverge/yamllint)
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: '3.x'
@@ -60,9 +60,9 @@ jobs:
permissions:
packages: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -18,13 +18,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get Version
shell: bash
@@ -32,14 +32,14 @@ jobs:
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -68,7 +68,7 @@ jobs:
type=semver,pattern=v{{major}}
- name: Build and push Docker image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@@ -80,7 +80,7 @@ jobs:
BUILD_VERSION=${{ env.BUILD_VERSION }}
- name: Export binaries from images
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
@@ -96,7 +96,7 @@ jobs:
done
- name: Upload binaries
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: binaries
path: binaries/wg-portal_linux*
@@ -110,7 +110,7 @@ jobs:
contents: write
steps:
- name: Download binaries
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: binaries

View File

@@ -15,11 +15,11 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: 3.x

View File

@@ -47,7 +47,7 @@ func main() {
rawDb, err := adapters.NewDatabase(cfg.Database)
internal.AssertNoError(err)
database, err := adapters.NewSqlRepository(rawDb, cfg)
database, err := adapters.NewSqlRepository(rawDb)
internal.AssertNoError(err)
wireGuard, err := wireguard.NewControllerManager(cfg)
@@ -135,7 +135,6 @@ func main() {
apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers)
apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard)
apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth)
apiV0EndpointWebsocket := handlersV0.NewWebsocketEndpoint(cfg, apiV0Auth, eventBus)
apiFrontend := handlersV0.NewRestApi(apiV0Session,
apiV0EndpointAuth,
@@ -145,7 +144,6 @@ func main() {
apiV0EndpointPeers,
apiV0EndpointConfig,
apiV0EndpointTest,
apiV0EndpointWebsocket,
)
// endregion API v0 (SPA frontend)

View File

@@ -157,14 +157,12 @@ More advanced options are found in the subsequent `Advanced` section.
### `create_default_peer`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER`
- **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for all server interfaces where the "Create default peer" flag is set.
- **Important:** This option is only effective for interfaces where the "Create default peer" flag is set (via the UI).
- **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for **all** server interfaces.
### `create_default_peer_on_creation`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION`
- **Description:** If an LDAP user is created (e.g., through LDAP sync) and has no peers, automatically create a new WireGuard peer for all server interfaces where the "Create default peer" flag is set.
- **Important:** This option requires [create_default_peer](#create_default_peer) to be enabled.
- **Description:** If an LDAP user is created (e.g., through LDAP sync) and has no peers, automatically create a new WireGuard peer for **all** server interfaces.
### `re_enable_peer_after_user_enable`
- **Default:** `true`

View File

@@ -9,11 +9,6 @@ Make sure that you download the correct binary for your architecture. The availa
- `wg-portal_linux_arm64` - Linux ARM 64-bit
- `wg-portal_linux_arm_v7` - Linux ARM 32-bit
### Released versions
To download a specific version, replace `${WG_PORTAL_VERSION}` with the desired version (or set an environment variable).
All official release versions can be found on the [GitHub Releases Page](https://github.com/h44z/wg-portal/releases).
With `curl`:
```shell
@@ -32,74 +27,16 @@ with `gh cli`:
gh release download ${WG_PORTAL_VERSION} --repo h44z/wg-portal --output wg-portal --pattern '*amd64'
```
The downloaded file will be named `wg-portal` and can be moved to a directory of your choice, see [Install](#install) for more information.
### Unreleased versions (master branch builds)
Unreleased versions can be fetched directly from the artifacts section of the [GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster).
## Install
The following command can be used to install the downloaded binary (`wg-portal`) to `/opt/wg-portal/wg-portal`. It ensures that the binary is executable.
```shell
sudo mkdir -p /opt/wg-portal
sudo install wg-portal /opt/wg-portal/
```
To handle tasks such as restarting the service or configuring automatic startup, it is recommended to use a process manager like [systemd](https://systemd.io/).
Refer to [Systemd Service Setup](#systemd-service-setup) for instructions.
## Unreleased versions (master branch builds)
## Systemd Service Setup
Unreleased versions can be fetched directly from the artifacts section of the [GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster).
> **Note:** To run WireGuard Portal as systemd service, you need to download the binary for your architecture beforehand.
>
> The following examples assume that you downloaded the binary to `/opt/wg-portal/wg-portal`.
> The configuration file is expected to be located at `/opt/wg-portal/config.yml`.
To run WireGuard Portal as a systemd service, you can create a service unit file. The easiest way to do this is by using `systemctl edit`:
```shell
sudo systemctl edit --force --full wg-portal.service
```
Paste the following content into the editor and adjust the variables to your needs:
```ini
[Unit]
Description=WireGuard Portal
ConditionPathExists=/opt/wg-portal/wg-portal
After=network.target
[Service]
Type=simple
User=root
Group=root
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
Restart=on-failure
RestartSec=10
WorkingDirectory=/opt/wg-portal
Environment=WG_PORTAL_CONFIG=/opt/wg-portal/config.yml
ExecStart=/opt/wg-portal/wg-portal
[Install]
WantedBy=multi-user.target
```
Alternatively, you can create or modify the file manually in `/etc/systemd/system/wg-portal.service`.
For systemd to pick up the changes, you need to reload the daemon:
```shell
sudo systemctl daemon-reload
```
After creating the service file, you can enable and start the service:
```shell
sudo systemctl enable --now wg-portal.service
```
To check status and log output, use: `sudo systemctl status wg-portal.service` or `sudo journalctl -u wg-portal.service`.

View File

@@ -4,7 +4,7 @@ To build the application from source files, use the Makefile provided in the rep
- [Git](https://git-scm.com/downloads)
- [Make](https://www.gnu.org/software/make/)
- [Go](https://go.dev/dl/): `>=1.25.0`
- [Go](https://go.dev/dl/): `>=1.24.0`
- [Node.js with npm](https://nodejs.org/en/download): `node>=18, npm>=9`
## Build

View File

@@ -443,18 +443,6 @@ definitions:
maxLength: 64
minLength: 32
type: string
AuthSources:
description: The source of the user. This field is optional.
example:
- db
items:
enum:
- db
- ldap
- oauth
type: string
readOnly: true
type: array
Department:
description: The department of the user. This field is optional.
example: Software Development
@@ -515,6 +503,19 @@ definitions:
description: The phone number of the user. This field is optional.
example: "+1234546789"
type: string
ProviderName:
description: The name of the authentication provider. This field is read-only.
example: ""
readOnly: true
type: string
Source:
description: The source of the user. This field is optional.
enum:
- db
- ldap
- oauth
example: db
type: string
required:
- Identifier
type: object

View File

@@ -1,152 +0,0 @@
WireGuard Portal supports multiple authentication mechanisms to manage user access. This includes
- Local user accounts
- LDAP authentication
- OAuth2 and OIDC authentication
- Passkey authentication (WebAuthn)
Users can have two roles which limit their permissions in WireGuard Portal:
- **User**: Can manage their own account and peers.
- **Admin**: Can manage all users and peers, including the ability to manage WireGuard interfaces.
In general, each user is identified by a _unique identifier_. If the same user identifier exists across multiple authentication sources, WireGuard Portal automatically merges those accounts into a single user record.
When a user is associated with multiple authentication sources, their information in WireGuard Portal is updated based on the most recently logged-in source. For more details, see [User Synchronization](./user-sync.md) documentation.
## Password Authentication
WireGuard Portal supports username and password authentication for both local and LDAP-backed accounts.
Local users are stored in the database, while LDAP users are authenticated against an external LDAP server.
On initial startup, WireGuard Portal automatically creates a local admin account with the password `wgportal-default`.
> :warning: This password must be changed immediately after the first login.
The minimum password length for all local users can be configured in the [`auth`](../configuration/overview.md#auth)
section of the configuration file. The default value is **16** characters, see [`min_password_length`](../configuration/overview.md#min_password_length).
The minimum password length is also enforced for the default admin user.
## Passkey (WebAuthn) Authentication
Besides the standard authentication mechanisms, WireGuard Portal supports Passkey authentication.
This feature is enabled by default and can be configured in the [`webauthn`](../configuration/overview.md#webauthn-passkeys) section of the configuration file.
Users can register multiple Passkeys to their account. These Passkeys can be used to log in to the web UI as long as the user is not locked.
> :warning: Passkey authentication does not disable password authentication. The password can still be used to log in (e.g., as a fallback).
To register a Passkey, open the settings page *(1)* in the web UI and click on the "Register Passkey" *(2)* button.
![Passkey UI](../../assets/images/passkey_setup.png)
## OAuth2 and OIDC Authentication
WireGuard Portal supports OAuth2 and OIDC authentication. You can use any OAuth2 or OIDC provider that supports the authorization code flow,
such as Google, GitHub, or Keycloak.
For OAuth2 or OIDC to work, you need to configure the [`external_url`](../configuration/overview.md#external_url) property in the [`web`](../configuration/overview.md#web) section of the configuration file.
If you are planning to expose the portal to the internet, make sure that the `external_url` is configured to use HTTPS.
To add OIDC or OAuth2 authentication to WireGuard Portal, create a Client-ID and Client-Secret in your OAuth2 provider and
configure a new authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
Make sure that each configured provider has a unique `provider_name` property set. Samples can be seen [here](../configuration/examples.md).
#### Limiting Login to Specific Domains
You can limit the login to specific domains by setting the `allowed_domains` property for OAuth2 or OIDC providers.
This property is a comma-separated list of domains that are allowed to log in. The user's email address is checked against this list.
For example, if you want to allow only users with an email address ending in `outlook.com` to log in, set the property as follows:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
allowed_domains:
- "outlook.com"
```
#### Limit Login to Existing Users
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for OAuth2 or OIDC providers.
If registration is enabled, new users will be created in the database when they log in for the first time.
#### Admin Mapping
You can map users to admin roles based on their attributes in the OAuth2 or OIDC provider. To do this, set the `admin_mapping` property for the provider.
Administrative access can either be mapped by a specific attribute or by group membership.
**Attribute specific mapping** can be achieved by setting the `admin_value_regex` and the `is_admin` property.
The `admin_value_regex` property is a regular expression that is matched against the value of the `is_admin` attribute.
The user is granted admin access if the regex matches the attribute value.
Example:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
field_map:
is_admin: "wg_admin_prop"
admin_mapping:
admin_value_regex: "^true$"
```
The example above will grant admin access to users with the `wg_admin_prop` attribute set to `true`.
**Group membership mapping** can be achieved by setting the `admin_group_regex` and `user_groups` property.
The `admin_group_regex` property is a regular expression that is matched against the group names of the user.
The user is granted admin access if the regex matches any of the group names.
Example:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
field_map:
user_groups: "groups"
admin_mapping:
admin_group_regex: "^the-admin-group$"
```
The example above will grant admin access to users who are members of the `the-admin-group` group.
## LDAP Authentication
WireGuard Portal supports LDAP authentication. You can use any LDAP server that supports the LDAP protocol, such as Active Directory or OpenLDAP.
Multiple LDAP servers can be configured in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
WireGuard Portal remembers the authentication provider of the user and therefore avoids conflicts between multiple LDAP providers.
To configure LDAP authentication, create a new [`ldap`](../configuration/overview.md#ldap) authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
### Limiting Login to Specific Users
You can limit the login to specific users by setting the `login_filter` property for LDAP provider. This filter uses the LDAP search filter syntax.
The username can be inserted into the query by placing the `{{login_identifier}}` placeholder in the filter. This placeholder will then be replaced with the username entered by the user during login.
For example, if you want to allow only users with the `objectClass` attribute set to `organizationalPerson` to log in, set the property as follows:
```yaml
auth:
ldap:
- provider_name: "ldap1"
# ... other settings
login_filter: "(&(objectClass=organizationalPerson)(uid={{login_identifier}}))"
```
The `login_filter` should always be designed to return at most one user.
### Limit Login to Existing Users
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for LDAP providers.
If registration is enabled, new users will be created in the database when they log in for the first time.
### Admin Mapping
You can map users to admin roles based on their group membership in the LDAP server. To do this, set the `admin_group` and `memberof` property for the provider.
The `admin_group` property defines the distinguished name of the group that is allowed to log in as admin.
All groups that are listed in the `memberof` attribute of the user will be checked against this group. If one of the groups matches, the user is granted admin access.
## User Synchronization

View File

@@ -14,7 +14,7 @@ WireGuard Interfaces can be categorized into three types:
## Accessing the Web UI
The web UI should be accessed via the URL specified in the `external_url` property of the configuration file.
By default, WireGuard Portal listens on port `8888` for HTTP connections. Check the [Security](security.md) or [Authentication](authentication.md) sections for more information on securing the web UI.
By default, WireGuard Portal listens on port `8888` for HTTP connections. Check the [Security](security.md) section for more information on securing the web UI.
So the default URL to access the web UI is:

View File

@@ -0,0 +1,37 @@
WireGuard Portal lets you hook up any LDAP server such as Active Directory or OpenLDAP for both authentication and user sync.
You can even register multiple LDAP servers side-by-side. When someone logs in via LDAP, their specific provider is remembered,
so there's no risk of cross-provider conflicts. Details on the log-in process can be found in the [Security](security.md#ldap-authentication) documentation.
If you enable LDAP synchronization, all users within the LDAP directory will be created automatically in the WireGuard Portal database if they do not exist.
If a user is disabled or deleted in LDAP, the user will be disabled in WireGuard Portal as well.
The synchronization process can be fine-tuned by multiple parameters, which are described below.
## LDAP Synchronization
WireGuard Portal can automatically synchronize users from LDAP to the database.
To enable this feature, set the `sync_interval` property in the LDAP provider configuration to a value greater than "0".
The value is a string representing a duration, such as "15m" for 15 minutes or "1h" for 1 hour (check the [exact format definition](https://pkg.go.dev/time#ParseDuration) for details).
The synchronization process will run in the background and synchronize users from LDAP to the database at the specified interval.
Also make sure that the `sync_filter` property is a well-formed LDAP filter, or synchronization will fail.
### Limiting Synchronization to Specific Users
Use the `sync_filter` property in your LDAP provider block to restrict which users get synchronized.
It accepts any valid LDAP search filter, only entries matching that filter will be pulled into the portal's database.
For example, to import only users with a `mail` attribute:
```yaml
auth:
ldap:
- id: ldap
# ... other settings
sync_filter: (mail=*)
```
### Disable Missing Users
If you set the `disable_missing` property to `true`, any user that is not found in LDAP during synchronization will be disabled in WireGuard Portal.
All peers associated with that user will also be disabled.
If you want a user and its peers to be automatically re-enabled once they are found in LDAP again, set the `auto_re_enable` property to `true`.
This will only re-enable the user if they where disabled by the synchronization process. Manually disabled users will not be re-enabled.

View File

@@ -1,12 +1,153 @@
This section describes the security features available to administrators for hardening WireGuard Portal and protecting its data.
## Database Encryption
## Authentication
WireGuard Portal supports multiple database backends. To reduce the risk of data exposure, sensitive information stored in the database can be encrypted.
To enable encryption, set the [`encryption_passphrase`](../configuration/overview.md#database) in the database configuration section.
WireGuard Portal supports multiple authentication methods, including:
- Local user accounts
- LDAP authentication
- OAuth and OIDC authentication
- Passkey authentication (WebAuthn)
Users can have two roles which limit their permissions in WireGuard Portal:
- **User**: Can manage their own account and peers.
- **Admin**: Can manage all users and peers, including the ability to manage WireGuard interfaces.
### Password Security
WireGuard Portal supports username and password authentication for both local and LDAP-backed accounts.
Local users are stored in the database, while LDAP users are authenticated against an external LDAP server.
On initial startup, WireGuard Portal automatically creates a local admin account with the password `wgportal-default`.
> :warning: This password must be changed immediately after the first login.
The minimum password length for all local users can be configured in the [`auth`](../configuration/overview.md#auth)
section of the configuration file. The default value is **16** characters, see [`min_password_length`](../configuration/overview.md#min_password_length).
The minimum password length is also enforced for the default admin user.
### Passkey (WebAuthn) Authentication
Besides the standard authentication mechanisms, WireGuard Portal supports Passkey authentication.
This feature is enabled by default and can be configured in the [`webauthn`](../configuration/overview.md#webauthn-passkeys) section of the configuration file.
Users can register multiple Passkeys to their account. These Passkeys can be used to log in to the web UI as long as the user is not locked.
> :warning: Passkey authentication does not disable password authentication. The password can still be used to log in (e.g., as a fallback).
To register a Passkey, open the settings page *(1)* in the web UI and click on the "Register Passkey" *(2)* button.
![Passkey UI](../../assets/images/passkey_setup.png)
### OAuth and OIDC Authentication
WireGuard Portal supports OAuth and OIDC authentication. You can use any OAuth or OIDC provider that supports the authorization code flow,
such as Google, GitHub, or Keycloak.
For OAuth or OIDC to work, you need to configure the [`external_url`](../configuration/overview.md#external_url) property in the [`web`](../configuration/overview.md#web) section of the configuration file.
If you are planning to expose the portal to the internet, make sure that the `external_url` is configured to use HTTPS.
To add OIDC or OAuth authentication to WireGuard Portal, create a Client-ID and Client-Secret in your OAuth provider and
configure a new authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
Make sure that each configured provider has a unique `provider_name` property set. Samples can be seen [here](../configuration/examples.md).
#### Limiting Login to Specific Domains
You can limit the login to specific domains by setting the `allowed_domains` property for OAuth or OIDC providers.
This property is a comma-separated list of domains that are allowed to log in. The user's email address is checked against this list.
For example, if you want to allow only users with an email address ending in `outlook.com` to log in, set the property as follows:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
allowed_domains:
- "outlook.com"
```
#### Limit Login to Existing Users
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for OAuth or OIDC providers.
If registration is enabled, new users will be created in the database when they log in for the first time.
#### Admin Mapping
You can map users to admin roles based on their attributes in the OAuth or OIDC provider. To do this, set the `admin_mapping` property for the provider.
Administrative access can either be mapped by a specific attribute or by group membership.
**Attribute specific mapping** can be achieved by setting the `admin_value_regex` and the `is_admin` property.
The `admin_value_regex` property is a regular expression that is matched against the value of the `is_admin` attribute.
The user is granted admin access if the regex matches the attribute value.
Example:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
field_map:
is_admin: "wg_admin_prop"
admin_mapping:
admin_value_regex: "^true$"
```
The example above will grant admin access to users with the `wg_admin_prop` attribute set to `true`.
**Group membership mapping** can be achieved by setting the `admin_group_regex` and `user_groups` property.
The `admin_group_regex` property is a regular expression that is matched against the group names of the user.
The user is granted admin access if the regex matches any of the group names.
Example:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
field_map:
user_groups: "groups"
admin_mapping:
admin_group_regex: "^the-admin-group$"
```
The example above will grant admin access to users who are members of the `the-admin-group` group.
### LDAP Authentication
WireGuard Portal supports LDAP authentication. You can use any LDAP server that supports the LDAP protocol, such as Active Directory or OpenLDAP.
Multiple LDAP servers can be configured in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
WireGuard Portal remembers the authentication provider of the user and therefore avoids conflicts between multiple LDAP providers.
To configure LDAP authentication, create a new [`ldap`](../configuration/overview.md#ldap) authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
#### Limiting Login to Specific Users
You can limit the login to specific users by setting the `login_filter` property for LDAP provider. This filter uses the LDAP search filter syntax.
The username can be inserted into the query by placing the `{{login_identifier}}` placeholder in the filter. This placeholder will then be replaced with the username entered by the user during login.
For example, if you want to allow only users with the `objectClass` attribute set to `organizationalPerson` to log in, set the property as follows:
```yaml
auth:
ldap:
- provider_name: "ldap1"
# ... other settings
login_filter: "(&(objectClass=organizationalPerson)(uid={{login_identifier}}))"
```
The `login_filter` should always be designed to return at most one user.
#### Limit Login to Existing Users
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for LDAP providers.
If registration is enabled, new users will be created in the database when they log in for the first time.
#### Admin Mapping
You can map users to admin roles based on their group membership in the LDAP server. To do this, set the `admin_group` and `memberof` property for the provider.
The `admin_group` property defines the distinguished name of the group that is allowed to log in as admin.
All groups that are listed in the `memberof` attribute of the user will be checked against this group. If one of the groups matches, the user is granted admin access.
> :warning: Important: Once encryption is enabled, it cannot be disabled, and the passphrase cannot be changed!
> Only new or updated records will be encrypted; existing data remains in plaintext until its next modified.
## UI and API Access
@@ -16,9 +157,4 @@ WireGuard Portal provides a web UI and a REST API for user interaction. It is im
It is recommended to use HTTPS for all communication with the portal to prevent eavesdropping.
Event though, WireGuard Portal supports HTTPS out of the box, it is recommended to use a reverse proxy like Nginx or Traefik to handle SSL termination and other security features.
A detailed explanation is available in the [Reverse Proxy](../getting-started/reverse-proxy.md) section.
### Secure Authentication
To prevent unauthorized access, WireGuard Portal supports integrating with secure authentication providers such as LDAP, OAuth2, or Passkeys, see [Authentication](./authentication.md) for more details.
When possible, use centralized authentication and enforce multi-factor authentication (MFA) at the provider level for enhanced account security.
For local accounts, administrators should enforce strong password requirements.
A detailed explanation is available in the [Reverse Proxy](../getting-started/reverse-proxy.md) section.

View File

@@ -1,46 +0,0 @@
For all external authentication providers (LDAP, OIDC, OAuth2), WireGuard Portal can automatically create a local user record upon the user's first successful login.
This behavior is controlled by the `registration_enabled` setting in each authentication provider's configuration.
User information from external authentication sources is merged into the corresponding local WireGuard Portal user record whenever the user logs in.
Additionally, WireGuard Portal supports periodic synchronization of user data from an LDAP directory.
To prevent overwriting local changes, WireGuard Portal allows you to set a per-user flag that disables synchronization of external attributes.
When this flag is set, the user in WireGuard Portal will not be updated automatically during log-ins or LDAP synchronization.
### LDAP Synchronization
WireGuard Portal lets you hook up any LDAP server such as Active Directory or OpenLDAP for both authentication and user sync.
You can even register multiple LDAP servers side-by-side. Details on the log-in process can be found in the [LDAP Authentication](./authentication.md#ldap-authentication) section.
If you enable LDAP synchronization, all users within the LDAP directory will be created automatically in the WireGuard Portal database if they do not exist.
If a user is disabled or deleted in LDAP, the user will be disabled in WireGuard Portal as well.
The synchronization process can be fine-tuned by multiple parameters, which are described below.
#### Synchronization Parameters
To enable the LDAP sycnhronization this feature, set the `sync_interval` property in the LDAP provider configuration to a value greater than "0".
The value is a string representing a duration, such as "15m" for 15 minutes or "1h" for 1 hour (check the [exact format definition](https://pkg.go.dev/time#ParseDuration) for details).
The synchronization process will run in the background and synchronize users from LDAP to the database at the specified interval.
Also make sure that the `sync_filter` property is a well-formed LDAP filter, or synchronization will fail.
##### Limiting Synchronization to Specific Users
Use the `sync_filter` property in your LDAP provider block to restrict which users get synchronized.
It accepts any valid LDAP search filter, only entries matching that filter will be pulled into the portal's database.
For example, to import only users with a `mail` attribute:
```yaml
auth:
ldap:
- id: ldap
# ... other settings
sync_filter: (mail=*)
```
##### Disable Missing Users
If you set the `disable_missing` property to `true`, any user that is not found in LDAP during synchronization will be disabled in WireGuard Portal.
All peers associated with that user will also be disabled.
If you want a user and its peers to be automatically re-enabled once they are found in LDAP again, set the `auto_re_enable` property to `true`.
This will only re-enable the user if they were disabled by the synchronization process. Manually disabled users will not be re-enabled.

View File

@@ -68,32 +68,26 @@ All payload models are encoded as JSON objects. Fields with empty values might b
#### User Payload (entity: `user`)
| JSON Field | Type | Description |
|----------------|---------------|-----------------------------------|
| CreatedBy | string | Creator identifier |
| UpdatedBy | string | Last updater identifier |
| CreatedAt | time.Time | Time of creation |
| UpdatedAt | time.Time | Time of last update |
| Identifier | string | Unique user identifier |
| Email | string | User email |
| AuthSources | []AuthSource | Authentication sources |
| IsAdmin | bool | Whether user has admin privileges |
| Firstname | string | User's first name (optional) |
| Lastname | string | User's last name (optional) |
| Phone | string | Contact phone number (optional) |
| Department | string | User's department (optional) |
| Notes | string | Additional notes (optional) |
| Disabled | *time.Time | When user was disabled |
| DisabledReason | string | Reason for deactivation |
| Locked | *time.Time | When user account was locked |
| LockedReason | string | Reason for being locked |
`AuthSource`:
| JSON Field | Type | Description |
|--------------|---------------|-----------------------------------------------------|
| Source | string | The authentication source (e.g. LDAP, OAuth, or DB) |
| ProviderName | string | The identifier of the authentication provider |
| JSON Field | Type | Description |
|----------------|-------------|-----------------------------------|
| CreatedBy | string | Creator identifier |
| UpdatedBy | string | Last updater identifier |
| CreatedAt | time.Time | Time of creation |
| UpdatedAt | time.Time | Time of last update |
| Identifier | string | Unique user identifier |
| Email | string | User email |
| Source | string | Authentication source |
| ProviderName | string | Name of auth provider |
| IsAdmin | bool | Whether user has admin privileges |
| Firstname | string | User's first name (optional) |
| Lastname | string | User's last name (optional) |
| Phone | string | Contact phone number (optional) |
| Department | string | User's department (optional) |
| Notes | string | Additional notes (optional) |
| Disabled | *time.Time | When user was disabled |
| DisabledReason | string | Reason for deactivation |
| Locked | *time.Time | When user account was locked |
| LockedReason | string | Reason for being locked |
#### Peer Payload (entity: `peer`)

View File

@@ -83,7 +83,6 @@ watch(() => props.visible, async (newValue, oldValue) => {
formData.value.Identifier = interfaces.Prepared.Identifier
formData.value.DisplayName = interfaces.Prepared.DisplayName
formData.value.Mode = interfaces.Prepared.Mode
formData.value.CreateDefaultPeer = interfaces.Prepared.CreateDefaultPeer
formData.value.Backend = interfaces.Prepared.Backend
formData.value.PublicKey = interfaces.Prepared.PublicKey
@@ -123,7 +122,6 @@ watch(() => props.visible, async (newValue, oldValue) => {
formData.value.Identifier = selectedInterface.value.Identifier
formData.value.DisplayName = selectedInterface.value.DisplayName
formData.value.Mode = selectedInterface.value.Mode
formData.value.CreateDefaultPeer = selectedInterface.value.CreateDefaultPeer
formData.value.Backend = selectedInterface.value.Backend
formData.value.PublicKey = selectedInterface.value.PublicKey
@@ -489,10 +487,6 @@ async function del() {
<input v-model="formData.Disabled" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label>
</div>
<div class="form-check form-switch" v-if="formData.Mode==='server' && settings.Setting('CreateDefaultPeer')">
<input v-model="formData.CreateDefaultPeer" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.create-default-peer.label') }}</label>
</div>
<div class="form-check form-switch" v-if="formData.Backend==='local'">
<input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label>

View File

@@ -42,7 +42,7 @@ const passwordWeak = computed(() => {
})
const formValid = computed(() => {
if (!formData.value.AuthSources.some(s => s === 'db')) {
if (formData.value.Source !== 'db') {
return true // nothing to validate
}
if (props.userId !== '#NEW#' && passwordWeak.value) {
@@ -70,7 +70,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
} else { // fill existing userdata
formData.value.Identifier = selectedUser.value.Identifier
formData.value.Email = selectedUser.value.Email
formData.value.AuthSources = selectedUser.value.AuthSources
formData.value.Source = selectedUser.value.Source
formData.value.IsAdmin = selectedUser.value.IsAdmin
formData.value.Firstname = selectedUser.value.Firstname
formData.value.Lastname = selectedUser.value.Lastname
@@ -80,7 +80,6 @@ watch(() => props.visible, async (newValue, oldValue) => {
formData.value.Password = ""
formData.value.Disabled = selectedUser.value.Disabled
formData.value.Locked = selectedUser.value.Locked
formData.value.PersistLocalChanges = selectedUser.value.PersistLocalChanges
}
}
}
@@ -134,7 +133,7 @@ async function del() {
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<fieldset>
<fieldset v-if="formData.Source==='db'">
<legend class="mt-4">{{ $t('modals.user-edit.header-general') }}</legend>
<div v-if="props.userId==='#NEW#'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.identifier.label') }}</label>
@@ -142,22 +141,16 @@ async function del() {
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.source.label') }}</label>
<input v-model="formData.AuthSources" class="form-control" disabled="disabled" :placeholder="$t('modals.user-edit.source.placeholder')" type="text">
<input v-model="formData.Source" class="form-control" disabled="disabled" :placeholder="$t('modals.user-edit.source.placeholder')" type="text">
</div>
<div class="form-group" v-if="formData.AuthSources.some(s => s ==='db')">
<div v-if="formData.Source==='db'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label>
<input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :class="{ 'is-invalid': passwordWeak, 'is-valid': formData.Password !== '' && !passwordWeak }" :placeholder="$t('modals.user-edit.password.placeholder')" type="password">
<div class="invalid-feedback">{{ $t('modals.user-edit.password.too-weak') }}</div>
<small v-if="props.userId!=='#NEW#'" id="passwordHelp" class="form-text text-muted">{{ $t('modals.user-edit.password.description') }}</small>
</div>
</fieldset>
<fieldset v-if="formData.AuthSources.some(s => s !=='db') && !formData.PersistLocalChanges">
<legend class="mt-4">{{ $t('modals.user-edit.header-personal') }}</legend>
<div class="alert alert-warning mt-3">
{{ $t('modals.user-edit.sync-warning') }}
</div>
</fieldset>
<fieldset v-if="!formData.AuthSources.some(s => s !=='db') || formData.PersistLocalChanges">
<fieldset v-if="formData.Source==='db'">
<legend class="mt-4">{{ $t('modals.user-edit.header-personal') }}</legend>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.email.label') }}</label>
@@ -201,14 +194,10 @@ async function del() {
<input v-model="formData.Locked" class="form-check-input" type="checkbox">
<label class="form-check-label" >{{ $t('modals.user-edit.locked.label') }}</label>
</div>
<div class="form-check form-switch" v-if="!formData.AuthSources.some(s => s !=='db') || formData.PersistLocalChanges">
<div class="form-check form-switch" v-if="formData.Source==='db'">
<input v-model="formData.IsAdmin" checked="" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.user-edit.admin.label') }}</label>
</div>
<div class="form-check form-switch" v-if="formData.AuthSources.some(s => s !=='db')">
<input v-model="formData.PersistLocalChanges" class="form-check-input" type="checkbox">
<label class="form-check-label" >{{ $t('modals.user-edit.persist-local-changes.label') }}</label>
</div>
</fieldset>
</template>

View File

@@ -1,7 +1,7 @@
export function base64_url_encode(input) {
let output = btoa(input)
output = output.replaceAll('+', '.')
output = output.replaceAll('/', '_')
output = output.replaceAll('=', '-')
output = output.replace('+', '.')
output = output.replace('/', '_')
output = output.replace('=', '-')
return output
}

View File

@@ -4,7 +4,6 @@ export function freshInterface() {
Disabled: false,
DisplayName: "",
Identifier: "",
CreateDefaultPeer: false,
Mode: "server",
Backend: "local",
@@ -137,7 +136,7 @@ export function freshUser() {
Identifier: "",
Email: "",
AuthSources: ["db"],
Source: "db",
IsAdmin: false,
Firstname: "",
@@ -155,8 +154,6 @@ export function freshUser() {
ApiEnabled: false,
PersistLocalChanges: false,
PeerCount: 0,
// Internal values

View File

@@ -1,86 +0,0 @@
import { peerStore } from '@/stores/peers';
import { interfaceStore } from '@/stores/interfaces';
import { authStore } from '@/stores/auth';
let socket = null;
let reconnectTimer = null;
let failureCount = 0;
export const websocketWrapper = {
connect() {
if (socket) {
console.log('WebSocket already connected, re-using existing connection.');
return;
}
const protocol = WGPORTAL_BACKEND_BASE_URL.startsWith('https://') ? 'wss://' : 'ws://';
const baseUrl = WGPORTAL_BACKEND_BASE_URL.replace(/^https?:\/\//, '');
const url = `${protocol}${baseUrl}/ws`;
socket = new WebSocket(url);
socket.onopen = () => {
console.log('WebSocket connected');
failureCount = 0;
if (reconnectTimer) {
clearInterval(reconnectTimer);
reconnectTimer = null;
}
};
socket.onclose = () => {
console.log('WebSocket disconnected');
failureCount++;
socket = null;
this.scheduleReconnect();
};
socket.onerror = (error) => {
console.error('WebSocket error:', error);
failureCount++;
socket.close();
socket = null;
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'peer_stats':
peerStore().updatePeerTrafficStats(message.data);
break;
case 'interface_stats':
interfaceStore().updateInterfaceTrafficStats(message.data);
break;
}
};
},
disconnect() {
if (socket) {
socket.close();
socket = null;
}
if (reconnectTimer) {
clearInterval(reconnectTimer);
reconnectTimer = null;
failureCount = 0;
}
},
scheduleReconnect() {
if (reconnectTimer) return;
if (!authStore().IsAuthenticated) return; // Don't reconnect if not logged in
reconnectTimer = setInterval(() => {
if (failureCount > 2) {
console.log('WebSocket connection unavailable, giving up.');
clearInterval(reconnectTimer);
reconnectTimer = null;
return;
}
console.log('Attempting to reconnect WebSocket...');
this.connect();
}, 5000);
}
};

View File

@@ -147,7 +147,7 @@
"email": "E-Mail",
"firstname": "Vorname",
"lastname": "Nachname",
"sources": "Quellen",
"source": "Quelle",
"peers": "Peers",
"admin": "Admin"
},
@@ -378,11 +378,7 @@
},
"admin": {
"label": "Ist Administrator"
},
"persist-local-changes": {
"label": "Lokale Änderungen speichern"
},
"sync-warning": "Um diesen synchronisierten Benutzer zu bearbeiten, aktivieren Sie die lokale Änderungsspeicherung. Andernfalls werden Ihre Änderungen bei der nächsten Synchronisierung überschrieben."
}
},
"interface-view": {
"headline": "Konfiguration für Schnittstelle:"
@@ -473,9 +469,6 @@
"disabled": {
"label": "Schnittstelle deaktiviert"
},
"create-default-peer": {
"label": "Peer für neue Benutzer automatisch erstellen"
},
"save-config": {
"label": "wg-quick Konfiguration automatisch speichern"
},

View File

@@ -147,7 +147,7 @@
"email": "E-Mail",
"firstname": "Firstname",
"lastname": "Lastname",
"sources": "Sources",
"source": "Source",
"peers": "Peers",
"admin": "Admin"
},
@@ -378,11 +378,7 @@
},
"admin": {
"label": "Is Admin"
},
"persist-local-changes": {
"label": "Persist local changes"
},
"sync-warning": "To modify this synchronized user, enable local change persistence. Otherwise, your changes will be overwritten during the next synchronization."
}
},
"interface-view": {
"headline": "Config for Interface:"
@@ -473,9 +469,6 @@
"disabled": {
"label": "Interface Disabled"
},
"create-default-peer": {
"label": "Create default peer for new users"
},
"save-config": {
"label": "Automatically save wg-quick config"
},

View File

@@ -162,7 +162,7 @@
"email": "Correo electrónico",
"firstname": "Nombre",
"lastname": "Apellido",
"sources": "Origen",
"source": "Origen",
"peers": "Peers",
"admin": "Administrador"
},

View File

@@ -137,7 +137,7 @@
"email": "E-mail",
"firstname": "Prénom",
"lastname": "Nom",
"sources": "Sources",
"source": "Source",
"peers": "Pairs",
"admin": "Admin"
},

View File

@@ -136,7 +136,7 @@
"email": "이메일",
"firstname": "이름",
"lastname": "성",
"sources": "소스",
"source": "소스",
"peers": "피어",
"admin": "관리자"
},

View File

@@ -137,7 +137,7 @@
"email": "E-Mail",
"firstname": "Primeiro Nome",
"lastname": "Último Nome",
"sources": "Fonte",
"source": "Fonte",
"peers": "Peers",
"admin": "Administrador"
},

View File

@@ -143,7 +143,7 @@
"email": "Электронная почта",
"firstname": "Имя",
"lastname": "Фамилия",
"sources": "Источник",
"source": "Источник",
"peers": "Пиры",
"admin": "Админ"
},

View File

@@ -135,7 +135,7 @@
"email": "E-Mail",
"firstname": "Ім'я",
"lastname": "Прізвище",
"sources": "Джерело",
"source": "Джерело",
"peers": "Піри",
"admin": "Адміністратор"
},

View File

@@ -134,7 +134,7 @@
"email": "E-Mail",
"firstname": "Tên",
"lastname": "Họ",
"sources": "Nguồn",
"source": "Nguồn",
"peers": "Peers",
"admin": "Quản trị viên"
},

View File

@@ -134,7 +134,7 @@
"email": "电子邮件",
"firstname": "名",
"lastname": "姓",
"sources": "来源",
"source": "来源",
"peers": "节点",
"admin": "管理员"
},

View File

@@ -2,7 +2,6 @@ import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '@/helpers/fetch-wrapper'
import { websocketWrapper } from '@/helpers/websocket-wrapper'
import router from '../router'
import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser';
import {base64_url_encode} from "@/helpers/encoding";
@@ -296,11 +295,9 @@ export const authStore = defineStore('auth',{
}
}
localStorage.setItem('user', JSON.stringify(this.user))
websocketWrapper.connect()
} else {
this.user = null
localStorage.removeItem('user')
websocketWrapper.disconnect()
}
},
setWebAuthnCredentials(credentials) {

View File

@@ -14,7 +14,6 @@ export const interfaceStore = defineStore('interfaces', {
configuration: "",
selected: "",
fetching: false,
trafficStats: {},
}),
getters: {
Count: (state) => state.interfaces.length,
@@ -25,9 +24,6 @@ export const interfaceStore = defineStore('interfaces', {
},
GetSelected: (state) => state.interfaces.find((i) => i.Identifier === state.selected) || state.interfaces[0],
isFetching: (state) => state.fetching,
TrafficStats: (state) => {
return (state.selected in state.trafficStats) ? state.trafficStats[state.selected] : { Received: 0, Transmitted: 0 }
},
},
actions: {
setInterfaces(interfaces) {
@@ -38,14 +34,6 @@ export const interfaceStore = defineStore('interfaces', {
this.selected = ""
}
this.fetching = false
this.trafficStats = {}
},
updateInterfaceTrafficStats(interfaceStats) {
const id = interfaceStats.EntityId;
this.trafficStats[id] = {
Received: interfaceStats.BytesReceived,
Transmitted: interfaceStats.BytesTransmitted,
};
},
async LoadInterfaces() {
this.fetching = true

View File

@@ -23,7 +23,6 @@ export const peerStore = defineStore('peers', {
fetching: false,
sortKey: 'IsConnected', // Default sort key
sortOrder: -1, // 1 for ascending, -1 for descending
trafficStats: {},
}),
getters: {
Find: (state) => {
@@ -77,9 +76,6 @@ export const peerStore = defineStore('peers', {
Statistics: (state) => {
return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats()
},
TrafficStats: (state) => {
return (id) => (id in state.trafficStats) ? state.trafficStats[id] : { Received: 0, Transmitted: 0 }
},
hasStatistics: (state) => state.statsEnabled,
},
@@ -115,7 +111,6 @@ export const peerStore = defineStore('peers', {
this.peers = peers
this.calculatePages()
this.fetching = false
this.trafficStats = {}
},
setPeer(peer) {
this.peer = peer
@@ -131,19 +126,11 @@ export const peerStore = defineStore('peers', {
if (!statsResponse) {
this.stats = {}
this.statsEnabled = false
this.trafficStats = {}
} else {
this.stats = statsResponse.Stats
this.statsEnabled = statsResponse.Enabled
}
},
updatePeerTrafficStats(peerStats) {
const id = peerStats.EntityId;
this.trafficStats[id] = {
Received: peerStats.BytesReceived,
Transmitted: peerStats.BytesTransmitted,
};
},
async Reset() {
this.setPeers([])
this.setStats(undefined)

View File

@@ -210,12 +210,6 @@ onMounted(async () => {
<div class="col-12 col-lg-8">
{{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{ $t('modals.interface-edit.mode.' + interfaces.GetSelected.Mode )}} | {{ $t('interfaces.interface.backend') + ": " + calculateBackendName }}<span v-if="!isBackendValid" :title="t('interfaces.interface.wrong-backend')" class="ms-1 me-1"><i class="fa-solid fa-triangle-exclamation"></i></span>)
<span v-if="interfaces.GetSelected.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="interfaces.GetSelected.DisabledReason"></i></span>
<div v-if="interfaces.GetSelected && (interfaces.TrafficStats.Received > 0 || interfaces.TrafficStats.Transmitted > 0)" class="mt-2">
<small class="text-muted">
Traffic: <i class="fa-solid fa-arrow-down me-1"></i>{{ humanFileSize(interfaces.TrafficStats.Received) }}/s
<i class="fa-solid fa-arrow-up ms-1 me-1"></i>{{ humanFileSize(interfaces.TrafficStats.Transmitted) }}/s
</small>
</div>
</div>
<div class="col-12 col-lg-4 text-lg-end">
<a class="btn-link" href="#" :title="$t('interfaces.interface.button-show-config')" @click.prevent="viewedInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-eye"></i></a>
@@ -457,19 +451,14 @@ onMounted(async () => {
<td v-if="interfaces.GetSelected.Mode==='client'">{{peer.Endpoint.Value}}</td>
<td v-if="peers.hasStatistics">
<div v-if="peers.Statistics(peer.Identifier).IsConnected">
<span class="badge rounded-pill bg-success" :title="$t('interfaces.peer-connected')"><i class="fa-solid fa-link"></i></span> <small class="text-muted" :title="$t('interfaces.peer-handshake') + ' ' + peers.Statistics(peer.Identifier).LastHandshake"><i class="fa-solid fa-circle-info"></i></small>
<span class="badge rounded-pill bg-success" :title="$t('interfaces.peer-connected')"><i class="fa-solid fa-link"></i></span> <span :title="$t('interfaces.peer-handshake') + ' ' + peers.Statistics(peer.Identifier).LastHandshake">{{ $t('interfaces.peer-connected') }}</span>
</div>
<div v-else>
<span class="badge rounded-pill bg-light" :title="$t('interfaces.peer-not-connected')"><i class="fa-solid fa-link-slash"></i></span>
</div>
</td>
<td v-if="peers.hasStatistics" >
<div class="d-flex flex-column">
<span :title="humanFileSize(peers.Statistics(peer.Identifier).BytesReceived) + ' / ' + humanFileSize(peers.Statistics(peer.Identifier).BytesTransmitted)">
<i class="fa-solid fa-arrow-down me-1"></i>{{ humanFileSize(peers.TrafficStats(peer.Identifier).Received) }}/s
<i class="fa-solid fa-arrow-up ms-1 me-1"></i>{{ humanFileSize(peers.TrafficStats(peer.Identifier).Transmitted) }}/s
</span>
</div>
<span class="text-center" >{{ humanFileSize(peers.Statistics(peer.Identifier).BytesReceived) }} / {{ humanFileSize(peers.Statistics(peer.Identifier).BytesTransmitted) }}</span>
</td>
<td class="text-center">
<a href="#" :title="$t('interfaces.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a>

View File

@@ -131,7 +131,7 @@ onMounted(() => {
<th scope="col">{{ $t('users.table-heading.email') }}</th>
<th scope="col">{{ $t('users.table-heading.firstname') }}</th>
<th scope="col">{{ $t('users.table-heading.lastname') }}</th>
<th class="text-center" scope="col">{{ $t('users.table-heading.sources') }}</th>
<th class="text-center" scope="col">{{ $t('users.table-heading.source') }}</th>
<th class="text-center" scope="col">{{ $t('users.table-heading.peers') }}</th>
<th class="text-center" scope="col">{{ $t('users.table-heading.admin') }}</th>
<th scope="col"></th><!-- Actions -->
@@ -150,7 +150,7 @@ onMounted(() => {
<td>{{user.Email}}</td>
<td>{{user.Firstname}}</td>
<td>{{user.Lastname}}</td>
<td><span class="badge bg-light me-1" v-for="src in user.AuthSources" :key="src">{{src}}</span></td>
<td class="text-center"><span class="badge rounded-pill bg-light">{{user.Source}}</span></td>
<td class="text-center">{{user.PeerCount}}</td>
<td class="text-center">
<span v-if="user.IsAdmin" class="text-danger" :title="$t('users.admin')"><i class="fa fa-check-circle"></i></span>

49
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/h44z/wg-portal
go 1.25.0
go 1.24.0
require (
github.com/a8m/envsubst v1.4.3
@@ -9,11 +9,10 @@ require (
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-pkgz/routegroup v1.6.0
github.com/go-playground/validator/v10 v10.30.1
github.com/go-playground/validator/v10 v10.28.0
github.com/go-webauthn/webauthn v0.15.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/prometheus-community/pro-bing v0.8.0
github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
github.com/swaggo/swag v1.16.6
@@ -22,9 +21,9 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sys v0.41.0
golang.org/x/crypto v0.46.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sys v0.39.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
@@ -34,7 +33,7 @@ require (
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -42,13 +41,13 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/spec v0.22.3 // indirect
github.com/go-openapi/spec v0.22.2 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
@@ -60,16 +59,16 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-test/deep v1.1.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-webauthn/x v0.1.26 // 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/sqlexp v0.1.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/go-tpm v0.9.7 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -78,12 +77,12 @@ require (
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.8.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/microsoft/go-mssqldb v1.9.6 // indirect
github.com/microsoft/go-mssqldb v1.9.5 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shopspring/decimal v1.4.0 // indirect
@@ -93,17 +92,17 @@ require (
github.com/yeqown/reedsolomon v1.0.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.68.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.67.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect
modernc.org/sqlite v1.40.1 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)

68
go.sum
View File

@@ -1,7 +1,5 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
@@ -52,10 +50,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -72,8 +68,6 @@ github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmG
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc=
github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
@@ -103,29 +97,23 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/go-webauthn/x v0.2.1 h1:/oB8i0FhSANuoN+YJF5XHMtppa7zGEYaQrrf6ytotjc=
github.com/go-webauthn/x v0.2.1/go.mod h1:Wm0X0zXkzznit4gHj4m82GiBZRMEm+TDUIoJWIQLsE4=
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.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
@@ -135,8 +123,6 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -144,8 +130,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
@@ -157,8 +141,6 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -201,8 +183,6 @@ github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTa
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw=
github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
@@ -219,16 +199,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -294,14 +270,10 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -310,8 +282,6 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -331,12 +301,8 @@ 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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -366,10 +332,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -398,10 +362,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -410,8 +372,6 @@ 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.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
@@ -419,8 +379,6 @@ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdI
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -444,20 +402,16 @@ modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -468,8 +422,6 @@ modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -23,6 +23,9 @@ import (
"github.com/h44z/wg-portal/internal/domain"
)
// SchemaVersion describes the current database schema version. It must be incremented if a manual migration is needed.
var SchemaVersion uint64 = 1
// SysStat stores the current database schema version and the timestamp when it was applied.
type SysStat struct {
MigratedAt time.Time `gorm:"column:migrated_at"`
@@ -176,15 +179,13 @@ func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
// SqlRepo is a SQL database repository implementation.
// Currently, it supports MySQL, SQLite, Microsoft SQL and Postgresql database systems.
type SqlRepo struct {
db *gorm.DB
cfg *config.Config
db *gorm.DB
}
// NewSqlRepository creates a new SqlRepo instance.
func NewSqlRepository(db *gorm.DB, cfg *config.Config) (*SqlRepo, error) {
func NewSqlRepository(db *gorm.DB) (*SqlRepo, error) {
repo := &SqlRepo{
db: db,
cfg: cfg,
db: db,
}
if err := repo.preCheck(); err != nil {
@@ -222,8 +223,6 @@ func (r *SqlRepo) preCheck() error {
func (r *SqlRepo) migrate() error {
slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{}))
slog.Debug("running migration: user", "result", r.db.AutoMigrate(&domain.User{}))
slog.Debug("running migration: user authentications", "result",
r.db.AutoMigrate(&domain.UserAuthentication{}))
slog.Debug("running migration: user webauthn credentials", "result",
r.db.AutoMigrate(&domain.UserWebauthnCredential{}))
slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{}))
@@ -233,88 +232,16 @@ func (r *SqlRepo) migrate() error {
slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{}))
existingSysStat := SysStat{}
r.db.Order("schema_version desc").First(&existingSysStat) // get latest version
// Migration: 0 --> 1
r.db.Where("schema_version = ?", SchemaVersion).First(&existingSysStat)
if existingSysStat.SchemaVersion == 0 {
const schemaVersion = 1
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
SchemaVersion: SchemaVersion,
}
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", SchemaVersion, err)
}
slog.Debug("sys-stat entry written", "schema_version", schemaVersion)
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
}
// Migration: 1 --> 2
if existingSysStat.SchemaVersion == 1 {
const schemaVersion = 2
// Preserve existing behavior for installations that had default-peer-creation enabled.
if r.cfg.Core.CreateDefaultPeer {
err := r.db.Model(&domain.Interface{}).
Where("type = ?", domain.InterfaceTypeServer).
Update("create_default_peer", true).Error
if err != nil {
return fmt.Errorf("failed to migrate interface flags for schema version %d: %w", schemaVersion, err)
}
slog.Debug("migrated interface create_default_peer flags", "schema_version", schemaVersion)
}
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
}
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
}
// Migration: 2 --> 3
if existingSysStat.SchemaVersion == 2 {
const schemaVersion = 3
// Migration to multi-auth
err := r.db.Transaction(func(tx *gorm.DB) error {
var users []domain.User
if err := tx.Find(&users).Error; err != nil {
return err
}
now := time.Now()
for _, user := range users {
auth := domain.UserAuthentication{
BaseModel: domain.BaseModel{
CreatedBy: domain.CtxSystemDBMigrator,
UpdatedBy: domain.CtxSystemDBMigrator,
CreatedAt: now,
UpdatedAt: now,
},
UserIdentifier: user.Identifier,
Source: user.Source,
ProviderName: user.ProviderName,
}
if err := tx.Create(&auth).Error; err != nil {
return err
}
}
slog.Debug("migrated users to multi-auth model", "schema_version", schemaVersion)
return nil
})
if err != nil {
return fmt.Errorf("failed to migrate to multi-auth: %w", err)
}
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
}
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
slog.Debug("sys-stat entry written", "schema_version", SchemaVersion)
}
return nil
@@ -824,7 +751,7 @@ func (r *SqlRepo) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr
func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
var user domain.User
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Preload("Authentications").First(&user, id).Error
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").First(&user, id).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound
@@ -842,8 +769,7 @@ func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domai
func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
var users []domain.User
err := r.db.WithContext(ctx).Where("email = ?",
email).Preload("WebAuthnCredentialList").Preload("Authentications").Find(&users).Error
err := r.db.WithContext(ctx).Where("email = ?", email).Preload("WebAuthnCredentialList").Find(&users).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound
}
@@ -883,7 +809,7 @@ func (r *SqlRepo) GetUserByWebAuthnCredential(ctx context.Context, credentialIdB
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
var users []domain.User
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Preload("Authentications").Find(&users).Error
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Find(&users).Error
if err != nil {
return nil, err
}
@@ -903,7 +829,6 @@ func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User,
Or("lastname LIKE ?", searchValue).
Or("email LIKE ?", searchValue).
Preload("WebAuthnCredentialList").
Preload("Authentications").
Find(&users).Error
if err != nil {
return nil, err
@@ -963,17 +888,7 @@ func (r *SqlRepo) getOrCreateUser(ui *domain.ContextUserInfo, tx *gorm.DB, id do
) {
var user domain.User
result := tx.Model(&user).Preload("WebAuthnCredentialList").Preload("Authentications").Find(&user, id)
if result.Error != nil {
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, result.Error
}
}
if result.Error == nil && result.RowsAffected > 0 {
return &user, nil
}
// create a new user record if no user record exists yet
// userDefaults will be applied to newly created user records
userDefaults := domain.User{
BaseModel: domain.BaseModel{
CreatedBy: ui.UserId(),
@@ -982,15 +897,16 @@ func (r *SqlRepo) getOrCreateUser(ui *domain.ContextUserInfo, tx *gorm.DB, id do
UpdatedAt: time.Now(),
},
Identifier: id,
Source: domain.UserSourceDatabase,
IsAdmin: false,
}
err := tx.Create(&userDefaults).Error
err := tx.Attrs(userDefaults).FirstOrCreate(&user, id).Error
if err != nil {
return nil, err
}
return &userDefaults, nil
return &user, nil
}
func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *domain.User) error {
@@ -1007,11 +923,6 @@ func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *doma
return fmt.Errorf("failed to update users webauthn credentials: %w", err)
}
err = tx.Session(&gorm.Session{FullSaveAssociations: true}).Unscoped().Model(user).Association("Authentications").Unscoped().Replace(user.Authentications)
if err != nil {
return fmt.Errorf("failed to update users authentications: %w", err)
}
return nil
}

View File

@@ -626,7 +626,7 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
if err != nil {
slog.Warn("failed to executed shell command",
"command", commandWithInterfaceName, "stdin", stdin, "output", string(out), "error", err)
return fmt.Errorf("failed to execute shell command %s: %w", commandWithInterfaceName, err)
return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err)
}
slog.Debug("executed shell command",
"command", commandWithInterfaceName,

View File

@@ -2676,12 +2676,6 @@
"ApiTokenCreated": {
"type": "string"
},
"AuthSources": {
"type": "array",
"items": {
"type": "string"
}
},
"Department": {
"type": "string"
},
@@ -2725,11 +2719,14 @@
"PeerCount": {
"type": "integer"
},
"PersistLocalChanges": {
"type": "boolean"
},
"Phone": {
"type": "string"
},
"ProviderName": {
"type": "string"
},
"Source": {
"type": "string"
}
}
},

View File

@@ -431,10 +431,6 @@ definitions:
type: string
ApiTokenCreated:
type: string
AuthSources:
items:
type: string
type: array
Department:
type: string
Disabled:
@@ -465,10 +461,12 @@ definitions:
type: string
PeerCount:
type: integer
PersistLocalChanges:
type: boolean
Phone:
type: string
ProviderName:
type: string
Source:
type: string
type: object
model.WebAuthnCredentialRequest:
properties:

View File

@@ -2132,22 +2132,6 @@
"minLength": 32,
"example": ""
},
"AuthSources": {
"description": "The source of the user. This field is optional.",
"type": "array",
"items": {
"type": "string",
"enum": [
"db",
"ldap",
"oauth"
]
},
"readOnly": true,
"example": [
"db"
]
},
"Department": {
"description": "The department of the user. This field is optional.",
"type": "string",
@@ -2221,6 +2205,22 @@
"description": "The phone number of the user. This field is optional.",
"type": "string",
"example": "+1234546789"
},
"ProviderName": {
"description": "The name of the authentication provider. This field is read-only.",
"type": "string",
"readOnly": true,
"example": ""
},
"Source": {
"description": "The source of the user. This field is optional.",
"type": "string",
"enum": [
"db",
"ldap",
"oauth"
],
"example": "db"
}
}
},

View File

@@ -490,18 +490,6 @@ definitions:
maxLength: 64
minLength: 32
type: string
AuthSources:
description: The source of the user. This field is optional.
example:
- db
items:
enum:
- db
- ldap
- oauth
type: string
readOnly: true
type: array
Department:
description: The department of the user. This field is optional.
example: Software Development
@@ -564,6 +552,19 @@ definitions:
description: The phone number of the user. This field is optional.
example: "+1234546789"
type: string
ProviderName:
description: The name of the authentication provider. This field is read-only.
example: ""
readOnly: true
type: string
Source:
description: The source of the user. This field is optional.
enum:
- db
- ldap
- oauth
example: db
type: string
required:
- Identifier
type: object

View File

@@ -1,8 +1,6 @@
package logging
import (
"bufio"
"net"
"net/http"
)
@@ -40,12 +38,6 @@ func (w *writerWrapper) Write(data []byte) (int, error) {
return n, err
}
// Hijack wraps the Hijack method of the ResponseWriter and returns the hijacked connection.
// This is required for websockets to work.
func (w *writerWrapper) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return http.NewResponseController(w.ResponseWriter).Hijack()
}
// newWriterWrapper returns a new writerWrapper that wraps the given http.ResponseWriter.
// It initializes the StatusCode to http.StatusOK.
func newWriterWrapper(w http.ResponseWriter) *writerWrapper {

View File

@@ -3,7 +3,6 @@ package backend
import (
"context"
"fmt"
"slices"
"strings"
"github.com/h44z/wg-portal/internal/config"
@@ -96,10 +95,8 @@ func (u UserService) ChangePassword(
}
// ensure that the user uses the database backend; otherwise we can't change the password
if !slices.ContainsFunc(user.Authentications, func(authentication domain.UserAuthentication) bool {
return authentication.Source == domain.UserSourceDatabase
}) {
return nil, fmt.Errorf("user has no linked authentication source that does support password changes")
if user.Source != domain.UserSourceDatabase {
return nil, fmt.Errorf("user source %s does not support password changes", user.Source)
}
// validate old password

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-pkgz/routegroup"
@@ -448,17 +449,7 @@ func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc {
// isValidReturnUrl checks if the given return URL matches the configured external URL of the application.
func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
expectedUrl, err := url.Parse(e.cfg.Web.ExternalUrl)
if err != nil {
return false
}
returnUrlParsed, err := url.Parse(returnUrl)
if err != nil {
return false
}
if returnUrlParsed.Scheme != expectedUrl.Scheme || returnUrlParsed.Host != expectedUrl.Host {
if !strings.HasPrefix(returnUrl, e.cfg.Web.ExternalUrl) {
return false
}

View File

@@ -145,7 +145,6 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
MinPasswordLength: e.cfg.Auth.MinPasswordLength,
AvailableBackends: controllerFn(),
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
CreateDefaultPeer: e.cfg.Core.CreateDefaultPeer,
})
}
}

View File

@@ -1,100 +0,0 @@
package handlers
import (
"context"
"net/http"
"strings"
"sync"
"github.com/go-pkgz/routegroup"
"github.com/gorilla/websocket"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
type WebsocketEventBus interface {
Subscribe(topic string, fn any) error
Unsubscribe(topic string, fn any) error
}
type WebsocketEndpoint struct {
authenticator Authenticator
bus WebsocketEventBus
upgrader websocket.Upgrader
}
func NewWebsocketEndpoint(cfg *config.Config, auth Authenticator, bus WebsocketEventBus) *WebsocketEndpoint {
return &WebsocketEndpoint{
authenticator: auth,
bus: bus,
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return strings.HasPrefix(origin, cfg.Web.ExternalUrl)
},
},
}
}
func (e WebsocketEndpoint) GetName() string {
return "WebsocketEndpoint"
}
func (e WebsocketEndpoint) RegisterRoutes(g *routegroup.Bundle) {
g.With(e.authenticator.LoggedIn()).HandleFunc("GET /ws", e.handleWebsocket())
}
// wsMessage represents a message sent over websocket to the frontend
type wsMessage struct {
Type string `json:"type"` // either "peer_stats" or "interface_stats"
Data any `json:"data"` // domain.TrafficDelta
}
func (e WebsocketEndpoint) handleWebsocket() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
conn, err := e.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
writeMutex := sync.Mutex{}
writeJSON := func(msg wsMessage) error {
writeMutex.Lock()
defer writeMutex.Unlock()
return conn.WriteJSON(msg)
}
peerStatsHandler := func(status domain.TrafficDelta) {
_ = writeJSON(wsMessage{Type: "peer_stats", Data: status})
}
interfaceStatsHandler := func(status domain.TrafficDelta) {
_ = writeJSON(wsMessage{Type: "interface_stats", Data: status})
}
_ = e.bus.Subscribe(app.TopicPeerStatsUpdated, peerStatsHandler)
defer e.bus.Unsubscribe(app.TopicPeerStatsUpdated, peerStatsHandler)
_ = e.bus.Subscribe(app.TopicInterfaceStatsUpdated, interfaceStatsHandler)
defer e.bus.Unsubscribe(app.TopicInterfaceStatsUpdated, interfaceStatsHandler)
// Keep connection open until client disconnects or context is cancelled
go func() {
for {
if _, _, err := conn.ReadMessage(); err != nil {
cancel()
return
}
}
}()
<-ctx.Done()
}
}

View File

@@ -14,7 +14,6 @@ type Settings struct {
MinPasswordLength int `json:"MinPasswordLength"`
AvailableBackends []SettingsBackendNames `json:"AvailableBackends"`
LoginFormVisible bool `json:"LoginFormVisible"`
CreateDefaultPeer bool `json:"CreateDefaultPeer"`
}
type SettingsBackendNames struct {

View File

@@ -9,16 +9,15 @@ import (
)
type Interface struct {
Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0
DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface
Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any'
Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ...
PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface
PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
DisabledReason string `json:"DisabledReason"` // the reason why the interface has been disabled
SaveConfig bool `json:"SaveConfig"` // automatically persist config changes to the wgX.conf file
CreateDefaultPeer bool `json:"CreateDefaultPeer"` // if true, default peers will be created for this interface
Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0
DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface
Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any'
Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ...
PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface
PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
DisabledReason string `json:"DisabledReason"` // the reason why the interface has been disabled
SaveConfig bool `json:"SaveConfig"` // automatically persist config changes to the wgX.conf file
ListenPort int `json:"ListenPort"` // the listening port, for example: 51820
Addresses []string `json:"Addresses"` // the interface ip addresses
@@ -66,7 +65,6 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason,
SaveConfig: src.SaveConfig,
CreateDefaultPeer: src.CreateDefaultPeer,
ListenPort: src.ListenPort,
Addresses: domain.CidrsToStringSlice(src.Addresses),
Dns: internal.SliceString(src.DnsStr),
@@ -153,7 +151,6 @@ func NewDomainInterface(src *Interface) *domain.Interface {
PreDown: src.PreDown,
PostDown: src.PostDown,
SaveConfig: src.SaveConfig,
CreateDefaultPeer: src.CreateDefaultPeer,
DisplayName: src.DisplayName,
Type: domain.InterfaceType(src.Mode),
Backend: domain.InterfaceBackend(src.Backend),

View File

@@ -3,15 +3,15 @@ package model
import (
"time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain"
)
type User struct {
Identifier string `json:"Identifier"`
Email string `json:"Email"`
AuthSources []string `json:"AuthSources"`
IsAdmin bool `json:"IsAdmin"`
Identifier string `json:"Identifier"`
Email string `json:"Email"`
Source string `json:"Source"`
ProviderName string `json:"ProviderName"`
IsAdmin bool `json:"IsAdmin"`
Firstname string `json:"Firstname"`
Lastname string `json:"Lastname"`
@@ -29,8 +29,6 @@ type User struct {
ApiTokenCreated *time.Time `json:"ApiTokenCreated,omitempty"`
ApiEnabled bool `json:"ApiEnabled"`
PersistLocalChanges bool `json:"PersistLocalChanges"`
// Calculated
PeerCount int `json:"PeerCount"`
@@ -38,26 +36,24 @@ type User struct {
func NewUser(src *domain.User, exposeCreds bool) *User {
u := &User{
Identifier: string(src.Identifier),
Email: src.Email,
AuthSources: internal.Map(src.Authentications, func(authentication domain.UserAuthentication) string {
return string(authentication.Source)
}),
IsAdmin: src.IsAdmin,
Firstname: src.Firstname,
Lastname: src.Lastname,
Phone: src.Phone,
Department: src.Department,
Notes: src.Notes,
Password: "", // never fill password
Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason,
Locked: src.IsLocked(),
LockedReason: src.LockedReason,
ApiToken: "", // by default, do not expose API token
ApiTokenCreated: src.ApiTokenCreated,
ApiEnabled: src.IsApiEnabled(),
PersistLocalChanges: src.PersistLocalChanges,
Identifier: string(src.Identifier),
Email: src.Email,
Source: string(src.Source),
ProviderName: src.ProviderName,
IsAdmin: src.IsAdmin,
Firstname: src.Firstname,
Lastname: src.Lastname,
Phone: src.Phone,
Department: src.Department,
Notes: src.Notes,
Password: "", // never fill password
Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason,
Locked: src.IsLocked(),
LockedReason: src.LockedReason,
ApiToken: "", // by default, do not expose API token
ApiTokenCreated: src.ApiTokenCreated,
ApiEnabled: src.IsApiEnabled(),
PeerCount: src.LinkedPeerCount,
}
@@ -81,21 +77,22 @@ func NewUsers(src []domain.User) []User {
func NewDomainUser(src *User) *domain.User {
now := time.Now()
res := &domain.User{
Identifier: domain.UserIdentifier(src.Identifier),
Email: src.Email,
IsAdmin: src.IsAdmin,
Firstname: src.Firstname,
Lastname: src.Lastname,
Phone: src.Phone,
Department: src.Department,
Notes: src.Notes,
Password: domain.PrivateString(src.Password),
Disabled: nil, // set below
DisabledReason: src.DisabledReason,
Locked: nil, // set below
LockedReason: src.LockedReason,
LinkedPeerCount: src.PeerCount,
PersistLocalChanges: src.PersistLocalChanges,
Identifier: domain.UserIdentifier(src.Identifier),
Email: src.Email,
Source: domain.UserSource(src.Source),
ProviderName: src.ProviderName,
IsAdmin: src.IsAdmin,
Firstname: src.Firstname,
Lastname: src.Lastname,
Phone: src.Phone,
Department: src.Department,
Notes: src.Notes,
Password: domain.PrivateString(src.Password),
Disabled: nil, // set below
DisabledReason: src.DisabledReason,
Locked: nil, // set below
LockedReason: src.LockedReason,
LinkedPeerCount: src.PeerCount,
}
if src.Disabled {

View File

@@ -3,7 +3,6 @@ package models
import (
"time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain"
)
@@ -14,7 +13,9 @@ type User struct {
// The email address of the user. This field is optional.
Email string `json:"Email" binding:"omitempty,email" example:"test@test.com"`
// The source of the user. This field is optional.
AuthSources []string `json:"AuthSources" readonly:"true" binding:"oneof=db ldap oauth" example:"db"`
Source string `json:"Source" binding:"oneof=db ldap oauth" example:"db"`
// The name of the authentication provider. This field is read-only.
ProviderName string `json:"ProviderName,omitempty" readonly:"true" example:""`
// If this field is set, the user is an admin.
IsAdmin bool `json:"IsAdmin" example:"false"`
@@ -51,11 +52,10 @@ type User struct {
func NewUser(src *domain.User, exposeCredentials bool) *User {
u := &User{
Identifier: string(src.Identifier),
Email: src.Email,
AuthSources: internal.Map(src.Authentications, func(authentication domain.UserAuthentication) string {
return string(authentication.Source)
}),
Identifier: string(src.Identifier),
Email: src.Email,
Source: string(src.Source),
ProviderName: src.ProviderName,
IsAdmin: src.IsAdmin,
Firstname: src.Firstname,
Lastname: src.Lastname,
@@ -93,6 +93,8 @@ func NewDomainUser(src *User) *domain.User {
res := &domain.User{
Identifier: domain.UserIdentifier(src.Identifier),
Email: src.Email,
Source: domain.UserSource(src.Source),
ProviderName: src.ProviderName,
IsAdmin: src.IsAdmin,
Firstname: src.Firstname,
Lastname: src.Lastname,

View File

@@ -129,6 +129,8 @@ func (a *App) createDefaultUser(ctx context.Context) error {
},
Identifier: adminUserId,
Email: "admin@wgportal.local",
Source: domain.UserSourceDatabase,
ProviderName: "",
IsAdmin: true,
Firstname: "WireGuard Portal",
Lastname: "Admin",

View File

@@ -29,8 +29,8 @@ type UserManager interface {
GetUser(context.Context, domain.UserIdentifier) (*domain.User, error)
// RegisterUser creates a new user in the database.
RegisterUser(ctx context.Context, user *domain.User) error
// UpdateUserInternal updates an existing user in the database.
UpdateUserInternal(ctx context.Context, user *domain.User) (*domain.User, error)
// UpdateUser updates an existing user in the database.
UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error)
}
type EventBus interface {
@@ -232,7 +232,7 @@ func (a *Authenticator) setupExternalAuthProviders(
}
for i := range ldap { // LDAP
providerCfg := &ldap[i]
providerId := strings.ToLower(providerCfg.ProviderName)
providerId := strings.ToLower(providerCfg.URL)
if _, exists := a.ldapAuthenticators[providerId]; exists {
// this is an unrecoverable error, we cannot register the same provider twice
@@ -354,45 +354,21 @@ func (a *Authenticator) passwordAuthentication(
var ldapProvider AuthenticatorLdap
var userInDatabase = false
var userSource domain.UserSource
existingUser, err := a.users.GetUser(ctx, identifier)
if err == nil {
userInDatabase = true
userSource = existingUser.Source
}
if userInDatabase && (existingUser.IsLocked() || existingUser.IsDisabled()) {
return nil, errors.New("user is locked")
}
authOK := false
if userInDatabase {
// User is already in db, search for authentication sources which support password authentication and
// validate the password.
for _, authentication := range existingUser.Authentications {
if authentication.Source == domain.UserSourceDatabase {
err := existingUser.CheckPassword(password)
if err == nil {
authOK = true
break
}
}
if authentication.Source == domain.UserSourceLdap {
ldapProvider, ok := a.ldapAuthenticators[strings.ToLower(authentication.ProviderName)]
if !ok {
continue // ldap provider not found, skip further checks
}
err := ldapProvider.PlaintextAuthentication(identifier, password)
if err == nil {
authOK = true
break
}
}
}
} else {
// User is not yet in the db, check ldap providers which have registration enabled.
// If the user is found, check the password - on success, sync it to the db.
if !userInDatabase || userSource == domain.UserSourceLdap {
// search user in ldap if registration is enabled
for _, ldapAuth := range a.ldapAuthenticators {
if !ldapAuth.RegistrationEnabled() {
continue // ldap provider does not support registration, skip further checks
if !userInDatabase && !ldapAuth.RegistrationEnabled() {
continue
}
rawUserInfo, err := ldapAuth.GetUserInfo(context.Background(), identifier)
@@ -403,39 +379,55 @@ func (a *Authenticator) passwordAuthentication(
}
continue // user not found / other ldap error
}
// user found, check if the password is correct
err = ldapAuth.PlaintextAuthentication(identifier, password)
if err != nil {
continue // password is incorrect, skip further checks
}
// create a new user in the db
ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo)
if err != nil {
slog.Error("failed to parse ldap user info",
"source", ldapAuth.GetName(), "identifier", identifier, "error", err)
continue
}
user, err := a.processUserInfo(ctx, ldapUserInfo, domain.UserSourceLdap, ldapProvider.GetName(), true)
if err != nil {
return nil, fmt.Errorf("unable to process user information: %w", err)
}
existingUser = user
slog.Debug("created new LDAP user in db",
"identifier", user.Identifier, "provider", ldapProvider.GetName())
// ldap user found
userSource = domain.UserSourceLdap
ldapProvider = ldapAuth
authOK = true
break
}
}
if !authOK {
return nil, errors.New("failed to authenticate user")
if userSource == "" {
slog.Warn("no user source found for user",
"identifier", identifier, "ldapProviderCount", len(a.ldapAuthenticators), "inDb", userInDatabase)
return nil, errors.New("user not found")
}
return existingUser, nil
if userSource == domain.UserSourceLdap && ldapProvider == nil {
slog.Warn("no ldap provider found for user",
"identifier", identifier, "ldapProviderCount", len(a.ldapAuthenticators), "inDb", userInDatabase)
return nil, errors.New("ldap provider not found")
}
switch userSource {
case domain.UserSourceDatabase:
err = existingUser.CheckPassword(password)
case domain.UserSourceLdap:
err = ldapProvider.PlaintextAuthentication(identifier, password)
default:
err = errors.New("no authentication backend available")
}
if err != nil {
return nil, fmt.Errorf("failed to authenticate: %w", err)
}
if !userInDatabase {
user, err := a.processUserInfo(ctx, ldapUserInfo, domain.UserSourceLdap, ldapProvider.GetName(),
ldapProvider.RegistrationEnabled())
if err != nil {
return nil, fmt.Errorf("unable to process user information: %w", err)
}
return user, nil
} else {
return existingUser, nil
}
}
// endregion password authentication
@@ -598,34 +590,17 @@ func (a *Authenticator) registerNewUser(
source domain.UserSource,
provider string,
) (*domain.User, error) {
ctxUserInfo := domain.GetUserInfo(ctx)
now := time.Now()
// convert user info to domain.User
user := &domain.User{
Identifier: userInfo.Identifier,
Email: userInfo.Email,
IsAdmin: false,
Firstname: userInfo.Firstname,
Lastname: userInfo.Lastname,
Phone: userInfo.Phone,
Department: userInfo.Department,
Authentications: []domain.UserAuthentication{
{
BaseModel: domain.BaseModel{
CreatedBy: ctxUserInfo.UserId(),
UpdatedBy: ctxUserInfo.UserId(),
CreatedAt: now,
UpdatedAt: now,
},
UserIdentifier: userInfo.Identifier,
Source: source,
ProviderName: provider,
},
},
}
if userInfo.AdminInfoAvailable && userInfo.IsAdmin {
user.IsAdmin = true
Identifier: userInfo.Identifier,
Email: userInfo.Email,
Source: source,
ProviderName: provider,
IsAdmin: userInfo.IsAdmin,
Firstname: userInfo.Firstname,
Lastname: userInfo.Lastname,
Phone: userInfo.Phone,
Department: userInfo.Department,
}
err := a.users.RegisterUser(ctx, user)
@@ -635,7 +610,6 @@ func (a *Authenticator) registerNewUser(
slog.Debug("registered user from external authentication provider",
"user", user.Identifier,
"adminInfoAvailable", userInfo.AdminInfoAvailable,
"isAdmin", user.IsAdmin,
"provider", source)
@@ -669,39 +643,6 @@ func (a *Authenticator) updateExternalUser(
return nil // user is locked or disabled, do not update
}
// Update authentication sources
foundAuthSource := false
for _, auth := range existingUser.Authentications {
if auth.Source == source && auth.ProviderName == provider {
foundAuthSource = true
break
}
}
if !foundAuthSource {
ctxUserInfo := domain.GetUserInfo(ctx)
now := time.Now()
existingUser.Authentications = append(existingUser.Authentications, domain.UserAuthentication{
BaseModel: domain.BaseModel{
CreatedBy: ctxUserInfo.UserId(),
UpdatedBy: ctxUserInfo.UserId(),
CreatedAt: now,
UpdatedAt: now,
},
UserIdentifier: existingUser.Identifier,
Source: source,
ProviderName: provider,
})
}
if existingUser.PersistLocalChanges {
if !foundAuthSource {
// Even if local changes are persisted, we need to save the new authentication source
_, err := a.users.UpdateUserInternal(ctx, existingUser)
return err
}
return nil
}
isChanged := false
if existingUser.Email != userInfo.Email {
existingUser.Email = userInfo.Email
@@ -723,23 +664,32 @@ func (a *Authenticator) updateExternalUser(
existingUser.Department = userInfo.Department
isChanged = true
}
if userInfo.AdminInfoAvailable && existingUser.IsAdmin != userInfo.IsAdmin {
if existingUser.IsAdmin != userInfo.IsAdmin {
existingUser.IsAdmin = userInfo.IsAdmin
isChanged = true
}
if isChanged || !foundAuthSource {
_, err := a.users.UpdateUserInternal(ctx, existingUser)
if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
slog.Debug("updated user with data from external authentication provider",
"user", existingUser.Identifier,
"adminInfoAvailable", userInfo.AdminInfoAvailable,
"isAdmin", existingUser.IsAdmin,
"provider", source)
if existingUser.Source != source {
existingUser.Source = source
isChanged = true
}
if existingUser.ProviderName != provider {
existingUser.ProviderName = provider
isChanged = true
}
if !isChanged {
return nil // nothing to update
}
_, err := a.users.UpdateUser(ctx, existingUser)
if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
slog.Debug("updated user with data from external authentication provider",
"user", existingUser.Identifier,
"isAdmin", existingUser.IsAdmin,
"provider", source)
return nil
}

View File

@@ -127,26 +127,18 @@ func (l LdapAuthenticator) GetUserInfo(_ context.Context, userId domain.UserIden
// ParseUserInfo parses the user information from the LDAP server into a domain.AuthenticatorUserInfo struct.
func (l LdapAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) {
isAdmin := false
adminInfoAvailable := false
if l.cfg.FieldMap.GroupMembership != "" {
adminInfoAvailable = true
var err error
isAdmin, err = internal.LdapIsMemberOf(raw[l.cfg.FieldMap.GroupMembership].([][]byte), l.cfg.ParsedAdminGroupDN)
if err != nil {
return nil, fmt.Errorf("failed to check admin group: %w", err)
}
isAdmin, err := internal.LdapIsMemberOf(raw[l.cfg.FieldMap.GroupMembership].([][]byte), l.cfg.ParsedAdminGroupDN)
if err != nil {
return nil, fmt.Errorf("failed to check admin group: %w", err)
}
userInfo := &domain.AuthenticatorUserInfo{
Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, l.cfg.FieldMap.UserIdentifier, "")),
Email: internal.MapDefaultString(raw, l.cfg.FieldMap.Email, ""),
Firstname: internal.MapDefaultString(raw, l.cfg.FieldMap.Firstname, ""),
Lastname: internal.MapDefaultString(raw, l.cfg.FieldMap.Lastname, ""),
Phone: internal.MapDefaultString(raw, l.cfg.FieldMap.Phone, ""),
Department: internal.MapDefaultString(raw, l.cfg.FieldMap.Department, ""),
IsAdmin: isAdmin,
AdminInfoAvailable: adminInfoAvailable,
Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, l.cfg.FieldMap.UserIdentifier, "")),
Email: internal.MapDefaultString(raw, l.cfg.FieldMap.Email, ""),
Firstname: internal.MapDefaultString(raw, l.cfg.FieldMap.Firstname, ""),
Lastname: internal.MapDefaultString(raw, l.cfg.FieldMap.Lastname, ""),
Phone: internal.MapDefaultString(raw, l.cfg.FieldMap.Phone, ""),
Department: internal.MapDefaultString(raw, l.cfg.FieldMap.Department, ""),
IsAdmin: isAdmin,
}
return userInfo, nil

View File

@@ -15,11 +15,9 @@ func parseOauthUserInfo(
raw map[string]any,
) (*domain.AuthenticatorUserInfo, error) {
var isAdmin bool
var adminInfoAvailable bool
// first try to match the is_admin field against the given regex
if mapping.IsAdmin != "" {
adminInfoAvailable = true
re := adminMapping.GetAdminValueRegex()
if re.MatchString(strings.TrimSpace(internal.MapDefaultString(raw, mapping.IsAdmin, ""))) {
isAdmin = true
@@ -28,7 +26,6 @@ func parseOauthUserInfo(
// next try to parse the user's groups
if !isAdmin && mapping.UserGroups != "" && adminMapping.AdminGroupRegex != "" {
adminInfoAvailable = true
userGroups := internal.MapDefaultStringSlice(raw, mapping.UserGroups, nil)
re := adminMapping.GetAdminGroupRegex()
for _, group := range userGroups {
@@ -40,14 +37,13 @@ func parseOauthUserInfo(
}
userInfo := &domain.AuthenticatorUserInfo{
Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, mapping.UserIdentifier, "")),
Email: internal.MapDefaultString(raw, mapping.Email, ""),
Firstname: internal.MapDefaultString(raw, mapping.Firstname, ""),
Lastname: internal.MapDefaultString(raw, mapping.Lastname, ""),
Phone: internal.MapDefaultString(raw, mapping.Phone, ""),
Department: internal.MapDefaultString(raw, mapping.Department, ""),
IsAdmin: isAdmin,
AdminInfoAvailable: adminInfoAvailable,
Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, mapping.UserIdentifier, "")),
Email: internal.MapDefaultString(raw, mapping.Email, ""),
Firstname: internal.MapDefaultString(raw, mapping.Firstname, ""),
Lastname: internal.MapDefaultString(raw, mapping.Lastname, ""),
Phone: internal.MapDefaultString(raw, mapping.Phone, ""),
Department: internal.MapDefaultString(raw, mapping.Department, ""),
IsAdmin: isAdmin,
}
return userInfo, nil

View File

@@ -23,8 +23,8 @@ type WebAuthnUserManager interface {
GetUser(context.Context, domain.UserIdentifier) (*domain.User, error)
// GetUserByWebAuthnCredential returns a user by its WebAuthn ID.
GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error)
// UpdateUserInternal updates an existing user in the database.
UpdateUserInternal(ctx context.Context, user *domain.User) (*domain.User, error)
// UpdateUser updates an existing user in the database.
UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error)
}
type WebAuthnAuthenticator struct {
@@ -89,7 +89,7 @@ func (a *WebAuthnAuthenticator) StartWebAuthnRegistration(ctx context.Context, u
if user.WebAuthnId == "" {
user.GenerateWebAuthnId()
user, err = a.users.UpdateUserInternal(ctx, user)
user, err = a.users.UpdateUser(ctx, user)
if err != nil {
return nil, nil, fmt.Errorf("failed to store webauthn id to user: %w", err)
}
@@ -150,7 +150,7 @@ func (a *WebAuthnAuthenticator) FinishWebAuthnRegistration(
return nil, err
}
user, err = a.users.UpdateUserInternal(ctx, user)
user, err = a.users.UpdateUser(ctx, user)
if err != nil {
return nil, err
}
@@ -181,7 +181,7 @@ func (a *WebAuthnAuthenticator) RemoveCredential(
}
user.RemoveCredential(credentialIdBase64)
user, err = a.users.UpdateUserInternal(ctx, user)
user, err = a.users.UpdateUser(ctx, user)
if err != nil {
return nil, err
}
@@ -205,7 +205,7 @@ func (a *WebAuthnAuthenticator) UpdateCredential(
return nil, err
}
user, err = a.users.UpdateUserInternal(ctx, user)
user, err = a.users.UpdateUser(ctx, user)
if err != nil {
return nil, err
}

View File

@@ -26,7 +26,6 @@ const TopicUserEnabled = "user:enabled"
const TopicInterfaceCreated = "interface:created"
const TopicInterfaceUpdated = "interface:updated"
const TopicInterfaceDeleted = "interface:deleted"
const TopicInterfaceStatsUpdated = "interface:stats:updated"
// endregion interface-events
@@ -38,7 +37,6 @@ const TopicPeerUpdated = "peer:updated"
const TopicPeerInterfaceUpdated = "peer:interface:updated"
const TopicPeerIdentifierUpdated = "peer:identifier:updated"
const TopicPeerStateChanged = "peer:state:changed"
const TopicPeerStatsUpdated = "peer:stats:updated"
// endregion peer-events

View File

@@ -190,21 +190,19 @@ func (m Manager) resolveEmail(ctx context.Context, peer *domain.Peer) (string, d
if err == nil {
slog.Debug("peer email: using user-identifier as email",
"peer", peer.Identifier, "email", peer.UserIdentifier)
return string(peer.UserIdentifier), domain.User{
Email: string(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", "peer has no user linked and user-identifier is not a valid email address")
"reason", "user has no user linked")
return "", domain.User{}
}
slog.Debug("peer email: skipping peer email",
"peer", peer.Identifier,
"reason", "user has no user linked")
return "", domain.User{}
}
if user.Email == "" {

View File

@@ -144,6 +144,8 @@ func migrateV1Users(oldDb, newDb *gorm.DB) error {
},
Identifier: domain.UserIdentifier(oldUser.Email),
Email: oldUser.Email,
Source: domain.UserSource(oldUser.Source),
ProviderName: "",
IsAdmin: oldUser.IsAdmin,
Firstname: oldUser.Firstname,
Lastname: oldUser.Lastname,
@@ -157,25 +159,11 @@ func migrateV1Users(oldDb, newDb *gorm.DB) error {
LockedReason: "",
LinkedPeerCount: 0,
}
if err := newDb.Create(&newUser).Error; err != nil {
return fmt.Errorf("failed to migrate user %s: %w", oldUser.Email, err)
}
authentication := domain.UserAuthentication{
BaseModel: domain.BaseModel{
CreatedBy: domain.CtxSystemV1Migrator,
UpdatedBy: domain.CtxSystemV1Migrator,
CreatedAt: oldUser.CreatedAt,
UpdatedAt: oldUser.UpdatedAt,
},
UserIdentifier: domain.UserIdentifier(oldUser.Email),
Source: domain.UserSource(oldUser.Source),
ProviderName: "", // unknown
}
if err := newDb.Create(&authentication).Error; err != nil {
return fmt.Errorf("failed to migrate user-authentication %s: %w", oldUser.Email, err)
}
slog.Debug("user migrated successfully", "identifier", newUser.Identifier)
}
@@ -358,6 +346,8 @@ func migrateV1Peers(oldDb, newDb *gorm.DB) error {
},
Identifier: domain.UserIdentifier(oldPeer.Email),
Email: oldPeer.Email,
Source: domain.UserSourceDatabase,
ProviderName: "",
IsAdmin: false,
Locked: &now,
LockedReason: domain.DisabledReasonMigrationDummy,
@@ -368,21 +358,6 @@ func migrateV1Peers(oldDb, newDb *gorm.DB) error {
return fmt.Errorf("failed to migrate dummy user %s: %w", oldPeer.Email, err)
}
authentication := domain.UserAuthentication{
BaseModel: domain.BaseModel{
CreatedBy: domain.CtxSystemV1Migrator,
UpdatedBy: domain.CtxSystemV1Migrator,
CreatedAt: now,
UpdatedAt: now,
},
UserIdentifier: domain.UserIdentifier(oldPeer.Email),
Source: domain.UserSourceDatabase,
ProviderName: "", // unknown
}
if err := newDb.Create(&authentication).Error; err != nil {
return fmt.Errorf("failed to migrate dummy user-authentication %s: %w", oldPeer.Email, err)
}
slog.Debug("dummy user migrated successfully", "identifier", user.Identifier)
}
newPeer := domain.Peer{

View File

@@ -2,7 +2,6 @@ package users
import (
"fmt"
"slices"
"strings"
"time"
@@ -26,8 +25,6 @@ func convertRawLdapUser(
return nil, fmt.Errorf("failed to check admin group: %w", err)
}
uid := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, ""))
return &domain.User{
BaseModel: domain.BaseModel{
CreatedBy: domain.CtxSystemLdapSyncer,
@@ -35,23 +32,18 @@ func convertRawLdapUser(
CreatedAt: now,
UpdatedAt: now,
},
Identifier: uid,
Email: strings.ToLower(internal.MapDefaultString(rawUser, fields.Email, "")),
IsAdmin: isAdmin,
Authentications: []domain.UserAuthentication{
{
UserIdentifier: uid,
Source: domain.UserSourceLdap,
ProviderName: providerName,
},
},
Firstname: internal.MapDefaultString(rawUser, fields.Firstname, ""),
Lastname: internal.MapDefaultString(rawUser, fields.Lastname, ""),
Phone: internal.MapDefaultString(rawUser, fields.Phone, ""),
Department: internal.MapDefaultString(rawUser, fields.Department, ""),
Notes: "",
Password: "",
Disabled: nil,
Identifier: domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, "")),
Email: strings.ToLower(internal.MapDefaultString(rawUser, fields.Email, "")),
Source: domain.UserSourceLdap,
ProviderName: providerName,
IsAdmin: isAdmin,
Firstname: internal.MapDefaultString(rawUser, fields.Firstname, ""),
Lastname: internal.MapDefaultString(rawUser, fields.Lastname, ""),
Phone: internal.MapDefaultString(rawUser, fields.Phone, ""),
Department: internal.MapDefaultString(rawUser, fields.Department, ""),
Notes: "",
Password: "",
Disabled: nil,
}, nil
}
@@ -80,9 +72,7 @@ func userChangedInLdap(dbUser, ldapUser *domain.User) bool {
return true
}
if !slices.ContainsFunc(dbUser.Authentications, func(authentication domain.UserAuthentication) bool {
return authentication.Source == ldapUser.Authentications[0].Source
}) {
if dbUser.ProviderName != ldapUser.ProviderName {
return true
}

View File

@@ -1,239 +0,0 @@
package users
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
func (m Manager) runLdapSynchronizationService(ctx context.Context) {
ctx = domain.SetUserInfo(ctx, domain.LdapSyncContextUserInfo()) // switch to service context for LDAP sync
for _, ldapCfg := range m.cfg.Auth.Ldap { // LDAP Auth providers
go func(cfg config.LdapProvider) {
syncInterval := cfg.SyncInterval
if syncInterval == 0 {
slog.Debug("sync disabled for LDAP server", "provider", cfg.ProviderName)
return
}
// perform initial sync
err := m.synchronizeLdapUsers(ctx, &cfg)
if err != nil {
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
} else {
slog.Debug("initial LDAP user sync completed", "provider", cfg.ProviderName)
}
// start periodic sync
running := true
for running {
select {
case <-ctx.Done():
running = false
continue
case <-time.After(syncInterval):
// select blocks until one of the cases evaluate to true
}
err := m.synchronizeLdapUsers(ctx, &cfg)
if err != nil {
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
}
}
}(ldapCfg)
}
}
func (m Manager) synchronizeLdapUsers(ctx context.Context, provider *config.LdapProvider) error {
slog.Debug("starting to synchronize users", "provider", provider.ProviderName)
dn, err := ldap.ParseDN(provider.AdminGroupDN)
if err != nil {
return fmt.Errorf("failed to parse admin group DN: %w", err)
}
provider.ParsedAdminGroupDN = dn
conn, err := internal.LdapConnect(provider)
if err != nil {
return fmt.Errorf("failed to setup LDAP connection: %w", err)
}
defer internal.LdapDisconnect(conn)
rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, provider.SyncFilter, &provider.FieldMap)
if err != nil {
return err
}
slog.Debug("fetched raw ldap users", "count", len(rawUsers), "provider", provider.ProviderName)
// Update existing LDAP users
err = m.updateLdapUsers(ctx, provider, rawUsers, &provider.FieldMap, provider.ParsedAdminGroupDN)
if err != nil {
return err
}
// Disable missing LDAP users
if provider.DisableMissing {
err = m.disableMissingLdapUsers(ctx, provider.ProviderName, rawUsers, &provider.FieldMap)
if err != nil {
return err
}
}
return nil
}
func (m Manager) updateLdapUsers(
ctx context.Context,
provider *config.LdapProvider,
rawUsers []internal.RawLdapUser,
fields *config.LdapFields,
adminGroupDN *ldap.DN,
) error {
for _, rawUser := range rawUsers {
user, err := convertRawLdapUser(provider.ProviderName, rawUser, fields, adminGroupDN)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err)
}
if provider.SyncLogUserInfo {
slog.Debug("ldap user data",
"raw-user", rawUser, "user", user.Identifier,
"is-admin", user.IsAdmin, "provider", provider.ProviderName)
}
existingUser, err := m.users.GetUser(ctx, user.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)
}
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
tctx = domain.SetUserInfo(tctx, domain.SystemAdminContextUserInfo())
if existingUser == nil {
// create new user
slog.Debug("creating new user from provider", "user", user.Identifier, "provider", provider.ProviderName)
_, err := m.create(tctx, user)
if err != nil {
cancel()
return fmt.Errorf("create error for user id %s: %w", user.Identifier, err)
}
} else {
// update existing user
if provider.AutoReEnable && existingUser.DisabledReason == domain.DisabledReasonLdapMissing {
user.Disabled = nil
user.DisabledReason = ""
} else {
user.Disabled = existingUser.Disabled
user.DisabledReason = existingUser.DisabledReason
}
if existingUser.PersistLocalChanges {
cancel()
continue // skip synchronization for this user
}
if userChangedInLdap(existingUser, user) {
syncedUser, err := m.users.GetUser(ctx, user.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
cancel()
return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)
}
syncedUser.UpdatedAt = time.Now()
syncedUser.UpdatedBy = domain.CtxSystemLdapSyncer
syncedUser.MergeAuthSources(user.Authentications...)
syncedUser.Email = user.Email
syncedUser.Firstname = user.Firstname
syncedUser.Lastname = user.Lastname
syncedUser.Phone = user.Phone
syncedUser.Department = user.Department
syncedUser.IsAdmin = user.IsAdmin
syncedUser.Disabled = user.Disabled
syncedUser.DisabledReason = user.DisabledReason
_, err = m.update(tctx, existingUser, syncedUser, false)
if err != nil {
cancel()
return fmt.Errorf("update error for user id %s: %w", user.Identifier, err)
}
}
}
cancel()
}
return nil
}
func (m Manager) disableMissingLdapUsers(
ctx context.Context,
providerName string,
rawUsers []internal.RawLdapUser,
fields *config.LdapFields,
) error {
allUsers, err := m.users.GetAllUsers(ctx)
if err != nil {
return err
}
for _, user := range allUsers {
userHasAuthSource := false
for _, auth := range user.Authentications {
if auth.Source == domain.UserSourceLdap && auth.ProviderName == providerName {
userHasAuthSource = true
break
}
}
if !userHasAuthSource {
continue // ignore non ldap users
}
if user.IsDisabled() {
continue // ignore deactivated
}
if user.PersistLocalChanges {
continue // skip sync for this user
}
existsInLDAP := false
for _, rawUser := range rawUsers {
userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, ""))
if user.Identifier == userId {
existsInLDAP = true
break
}
}
if existsInLDAP {
continue
}
slog.Debug("user is missing in ldap provider, disabling", "user", user.Identifier, "provider", providerName)
now := time.Now()
user.Disabled = &now
user.DisabledReason = domain.DisabledReasonLdapMissing
err := m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
u.Disabled = user.Disabled
u.DisabledReason = user.DisabledReason
return u, nil
})
if err != nil {
return fmt.Errorf("disable error for user id %s: %w", user.Identifier, err)
}
m.bus.Publish(app.TopicUserDisabled, user)
}
return nil
}

View File

@@ -4,12 +4,15 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"sync"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
@@ -76,7 +79,7 @@ func (m Manager) RegisterUser(ctx context.Context, user *domain.User) error {
return err
}
createdUser, err := m.create(ctx, user)
createdUser, err := m.CreateUser(ctx, user)
if err != nil {
return err
}
@@ -98,11 +101,20 @@ func (m Manager) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain
return nil, err
}
return m.getUser(ctx, id)
user, err := m.users.GetUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("unable to load user %s: %w", id, err)
}
peers, _ := m.peers.GetUserPeers(ctx, id) // ignore error, list will be empty in error case
user.LinkedPeerCount = len(peers)
return user, nil
}
// GetUserByEmail returns the user with the given email address.
func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
user, err := m.users.GetUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("unable to load user for email %s: %w", email, err)
@@ -112,11 +124,16 @@ func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User
return nil, err
}
return m.enrichUser(ctx, user), nil
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case
user.LinkedPeerCount = len(peers)
return user, nil
}
// GetUserByWebAuthnCredential returns the user for the given WebAuthn credential.
func (m Manager) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) {
user, err := m.users.GetUserByWebAuthnCredential(ctx, credentialIdBase64)
if err != nil {
return nil, fmt.Errorf("unable to load user for webauthn credential %s: %w", credentialIdBase64, err)
@@ -126,7 +143,11 @@ func (m Manager) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBa
return nil, err
}
return m.enrichUser(ctx, user), nil
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case
user.LinkedPeerCount = len(peers)
return user, nil
}
// GetAllUsers returns all users.
@@ -148,7 +169,8 @@ func (m Manager) GetAllUsers(ctx context.Context) ([]domain.User, error) {
go func() {
defer wg.Done()
for user := range ch {
m.enrichUser(ctx, user)
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case
user.LinkedPeerCount = len(peers)
}
}()
}
@@ -172,29 +194,77 @@ func (m Manager) UpdateUser(ctx context.Context, user *domain.User) (*domain.Use
return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
}
user.CopyCalculatedAttributes(existingUser, true) // ensure that crucial attributes stay the same
return m.update(ctx, existingUser, user, true)
}
// UpdateUserInternal updates the user with the given identifier. This function must never be called from external.
// This function allows to override authentications and webauthn credentials.
func (m Manager) UpdateUserInternal(ctx context.Context, user *domain.User) (*domain.User, error) {
existingUser, err := m.users.GetUser(ctx, user.Identifier)
if err != nil {
return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
if err := m.validateModifications(ctx, existingUser, user); err != nil {
return nil, fmt.Errorf("update not allowed: %w", err)
}
return m.update(ctx, existingUser, user, false)
user.CopyCalculatedAttributes(existingUser)
err = user.HashPassword()
if err != nil {
return nil, err
}
if user.Password == "" { // keep old password
user.Password = existingUser.Password
}
err = m.users.SaveUser(ctx, existingUser.Identifier, func(u *domain.User) (*domain.User, error) {
user.CopyCalculatedAttributes(u)
return user, nil
})
if err != nil {
return nil, fmt.Errorf("update failure: %w", err)
}
m.bus.Publish(app.TopicUserUpdated, *user)
switch {
case !existingUser.IsDisabled() && user.IsDisabled():
m.bus.Publish(app.TopicUserDisabled, *user)
case existingUser.IsDisabled() && !user.IsDisabled():
m.bus.Publish(app.TopicUserEnabled, *user)
}
return user, nil
}
// CreateUser creates a new user.
func (m Manager) CreateUser(ctx context.Context, user *domain.User) (*domain.User, error) {
if user.Identifier == "" {
return nil, errors.New("missing user identifier")
}
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
}
return m.create(ctx, user)
existingUser, err := m.users.GetUser(ctx, user.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
}
if existingUser != nil {
return nil, errors.Join(fmt.Errorf("user %s already exists", user.Identifier), domain.ErrDuplicateEntry)
}
if err := m.validateCreation(ctx, user); err != nil {
return nil, fmt.Errorf("creation not allowed: %w", err)
}
err = user.HashPassword()
if err != nil {
return nil, err
}
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
user.CopyCalculatedAttributes(u)
return user, nil
})
if err != nil {
return nil, fmt.Errorf("creation failure: %w", err)
}
m.bus.Publish(app.TopicUserCreated, *user)
return user, nil
}
// DeleteUser deletes the user with the given identifier.
@@ -237,10 +307,15 @@ func (m Manager) ActivateApi(ctx context.Context, id domain.UserIdentifier) (*do
user.ApiToken = uuid.New().String()
user.ApiTokenCreated = &now
user, err = m.update(ctx, user, user, true) // self-update
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
user.CopyCalculatedAttributes(u)
return user, nil
})
if err != nil {
return nil, err
return nil, fmt.Errorf("update failure: %w", err)
}
m.bus.Publish(app.TopicUserUpdated, *user)
m.bus.Publish(app.TopicUserApiEnabled, *user)
return user, nil
@@ -260,10 +335,15 @@ func (m Manager) DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*
user.ApiToken = ""
user.ApiTokenCreated = nil
user, err = m.update(ctx, user, user, true) // self-update
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
user.CopyCalculatedAttributes(u)
return user, nil
})
if err != nil {
return nil, err
return nil, fmt.Errorf("update failure: %w", err)
}
m.bus.Publish(app.TopicUserUpdated, *user)
m.bus.Publish(app.TopicUserApiDisabled, *user)
return user, nil
@@ -300,6 +380,10 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Use
return fmt.Errorf("cannot lock own user: %w", domain.ErrInvalidData)
}
if old.Source != new.Source {
return fmt.Errorf("cannot change user source: %w", domain.ErrInvalidData)
}
return nil
}
@@ -330,19 +414,14 @@ func (m Manager) validateCreation(ctx context.Context, new *domain.User) error {
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}
if len(new.Authentications) != 1 {
return fmt.Errorf("invalid number of authentications: %d, expected 1: %w",
len(new.Authentications), domain.ErrInvalidData)
}
// Admins are allowed to create users for arbitrary sources.
if new.Authentications[0].Source != domain.UserSourceDatabase && !currentUser.IsAdmin {
if new.Source != domain.UserSourceDatabase && !currentUser.IsAdmin {
return fmt.Errorf("invalid user source: %s, only %s is allowed: %w",
new.Authentications[0].Source, domain.UserSourceDatabase, domain.ErrInvalidData)
new.Source, domain.UserSourceDatabase, domain.ErrInvalidData)
}
// database users must have a password
if new.Authentications[0].Source == domain.UserSourceDatabase && string(new.Password) == "" {
if new.Source == domain.UserSourceDatabase && string(new.Password) == "" {
return fmt.Errorf("missing password: %w", domain.ErrInvalidData)
}
@@ -381,112 +460,214 @@ func (m Manager) validateApiChange(ctx context.Context, user *domain.User) error
return nil
}
// region internal-modifiers
func (m Manager) runLdapSynchronizationService(ctx context.Context) {
ctx = domain.SetUserInfo(ctx, domain.LdapSyncContextUserInfo()) // switch to service context for LDAP sync
func (m Manager) enrichUser(ctx context.Context, user *domain.User) *domain.User {
if user == nil {
return nil
for _, ldapCfg := range m.cfg.Auth.Ldap { // LDAP Auth providers
go func(cfg config.LdapProvider) {
syncInterval := cfg.SyncInterval
if syncInterval == 0 {
slog.Debug("sync disabled for LDAP server", "provider", cfg.ProviderName)
return
}
// perform initial sync
err := m.synchronizeLdapUsers(ctx, &cfg)
if err != nil {
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
} else {
slog.Debug("initial LDAP user sync completed", "provider", cfg.ProviderName)
}
// start periodic sync
running := true
for running {
select {
case <-ctx.Done():
running = false
continue
case <-time.After(syncInterval):
// select blocks until one of the cases evaluate to true
}
err := m.synchronizeLdapUsers(ctx, &cfg)
if err != nil {
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
}
}
}(ldapCfg)
}
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case
user.LinkedPeerCount = len(peers)
return user
}
func (m Manager) getUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
user, err := m.users.GetUser(ctx, id)
func (m Manager) synchronizeLdapUsers(ctx context.Context, provider *config.LdapProvider) error {
slog.Debug("starting to synchronize users", "provider", provider.ProviderName)
dn, err := ldap.ParseDN(provider.AdminGroupDN)
if err != nil {
return nil, fmt.Errorf("unable to load user %s: %w", id, err)
return fmt.Errorf("failed to parse admin group DN: %w", err)
}
return m.enrichUser(ctx, user), nil
}
provider.ParsedAdminGroupDN = dn
func (m Manager) update(ctx context.Context, existingUser, user *domain.User, keepAuthentications bool) (
*domain.User,
error,
) {
if err := m.validateModifications(ctx, existingUser, user); err != nil {
return nil, fmt.Errorf("update not allowed: %w", err)
}
err := user.HashPassword()
conn, err := internal.LdapConnect(provider)
if err != nil {
return nil, err
}
if user.Password == "" { // keep old password
user.Password = existingUser.Password
return fmt.Errorf("failed to setup LDAP connection: %w", err)
}
defer internal.LdapDisconnect(conn)
err = m.users.SaveUser(ctx, existingUser.Identifier, func(u *domain.User) (*domain.User, error) {
user.CopyCalculatedAttributes(u, keepAuthentications)
return user, nil
})
rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, provider.SyncFilter, &provider.FieldMap)
if err != nil {
return nil, fmt.Errorf("update failure: %w", err)
return err
}
m.bus.Publish(app.TopicUserUpdated, *user)
slog.Debug("fetched raw ldap users", "count", len(rawUsers), "provider", provider.ProviderName)
switch {
case !existingUser.IsDisabled() && user.IsDisabled():
m.bus.Publish(app.TopicUserDisabled, *user)
case existingUser.IsDisabled() && !user.IsDisabled():
m.bus.Publish(app.TopicUserEnabled, *user)
// Update existing LDAP users
err = m.updateLdapUsers(ctx, provider, rawUsers, &provider.FieldMap, provider.ParsedAdminGroupDN)
if err != nil {
return err
}
return user, nil
}
func (m Manager) create(ctx context.Context, user *domain.User) (*domain.User, error) {
if user.Identifier == "" {
return nil, errors.New("missing user identifier")
}
existingUser, err := m.users.GetUser(ctx, user.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
}
if existingUser != nil {
return nil, errors.Join(fmt.Errorf("user %s already exists", user.Identifier), domain.ErrDuplicateEntry)
}
// Add default authentication if missing
if len(user.Authentications) == 0 {
ctxUserInfo := domain.GetUserInfo(ctx)
now := time.Now()
user.Authentications = []domain.UserAuthentication{
{
BaseModel: domain.BaseModel{
CreatedBy: ctxUserInfo.UserId(),
UpdatedBy: ctxUserInfo.UserId(),
CreatedAt: now,
UpdatedAt: now,
},
UserIdentifier: user.Identifier,
Source: domain.UserSourceDatabase,
ProviderName: "",
},
// Disable missing LDAP users
if provider.DisableMissing {
err = m.disableMissingLdapUsers(ctx, provider.ProviderName, rawUsers, &provider.FieldMap)
if err != nil {
return err
}
}
if err := m.validateCreation(ctx, user); err != nil {
return nil, fmt.Errorf("creation not allowed: %w", err)
}
err = user.HashPassword()
if err != nil {
return nil, err
}
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
return user, nil
})
if err != nil {
return nil, fmt.Errorf("creation failure: %w", err)
}
m.bus.Publish(app.TopicUserCreated, *user)
return user, nil
return nil
}
// endregion internal-modifiers
func (m Manager) updateLdapUsers(
ctx context.Context,
provider *config.LdapProvider,
rawUsers []internal.RawLdapUser,
fields *config.LdapFields,
adminGroupDN *ldap.DN,
) error {
for _, rawUser := range rawUsers {
user, err := convertRawLdapUser(provider.ProviderName, rawUser, fields, adminGroupDN)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err)
}
if provider.SyncLogUserInfo {
slog.Debug("ldap user data",
"raw-user", rawUser, "user", user.Identifier,
"is-admin", user.IsAdmin, "provider", provider.ProviderName)
}
existingUser, err := m.users.GetUser(ctx, user.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)
}
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
tctx = domain.SetUserInfo(tctx, domain.SystemAdminContextUserInfo())
if existingUser == nil {
// create new user
slog.Debug("creating new user from provider", "user", user.Identifier, "provider", provider.ProviderName)
_, err := m.CreateUser(tctx, user)
if err != nil {
cancel()
return fmt.Errorf("create error for user id %s: %w", user.Identifier, err)
}
} else {
// update existing user
if provider.AutoReEnable && existingUser.DisabledReason == domain.DisabledReasonLdapMissing {
user.Disabled = nil
user.DisabledReason = ""
} else {
user.Disabled = existingUser.Disabled
user.DisabledReason = existingUser.DisabledReason
}
if existingUser.Source == domain.UserSourceLdap && userChangedInLdap(existingUser, user) {
err := m.users.SaveUser(tctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
u.UpdatedAt = time.Now()
u.UpdatedBy = domain.CtxSystemLdapSyncer
u.Source = user.Source
u.ProviderName = user.ProviderName
u.Email = user.Email
u.Firstname = user.Firstname
u.Lastname = user.Lastname
u.Phone = user.Phone
u.Department = user.Department
u.IsAdmin = user.IsAdmin
u.Disabled = nil
u.DisabledReason = ""
return u, nil
})
if err != nil {
cancel()
return fmt.Errorf("update error for user id %s: %w", user.Identifier, err)
}
if existingUser.IsDisabled() && !user.IsDisabled() {
m.bus.Publish(app.TopicUserEnabled, *user)
}
}
}
cancel()
}
return nil
}
func (m Manager) disableMissingLdapUsers(
ctx context.Context,
providerName string,
rawUsers []internal.RawLdapUser,
fields *config.LdapFields,
) error {
allUsers, err := m.users.GetAllUsers(ctx)
if err != nil {
return err
}
for _, user := range allUsers {
if user.Source != domain.UserSourceLdap {
continue // ignore non ldap users
}
if user.ProviderName != providerName {
continue // user was synchronized through different provider
}
if user.IsDisabled() {
continue // ignore deactivated
}
existsInLDAP := false
for _, rawUser := range rawUsers {
userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, ""))
if user.Identifier == userId {
existsInLDAP = true
break
}
}
if existsInLDAP {
continue
}
slog.Debug("user is missing in ldap provider, disabling", "user", user.Identifier, "provider", providerName)
now := time.Now()
user.Disabled = &now
user.DisabledReason = domain.DisabledReasonLdapMissing
err := m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
u.Disabled = user.Disabled
u.DisabledReason = user.DisabledReason
return u, nil
})
if err != nil {
return fmt.Errorf("disable error for user id %s: %w", user.Identifier, err)
}
m.bus.Publish(app.TopicUserDisabled, user)
}
return nil
}

View File

@@ -3,7 +3,6 @@ package models
import (
"time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain"
)
@@ -14,10 +13,11 @@ type User struct {
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
Identifier string `json:"Identifier"`
Email string `json:"Email"`
AuthSources []UserAuthSource `json:"AuthSources"`
IsAdmin bool `json:"IsAdmin"`
Identifier string `json:"Identifier"`
Email string `json:"Email"`
Source string `json:"Source"`
ProviderName string `json:"ProviderName"`
IsAdmin bool `json:"IsAdmin"`
Firstname string `json:"Firstname,omitempty"`
Lastname string `json:"Lastname,omitempty"`
@@ -31,22 +31,8 @@ type User struct {
LockedReason string `json:"LockedReason,omitempty"`
}
// UserAuthSource represents a single authentication source for a user.
// For details about the fields, see the domain.UserAuthentication struct.
type UserAuthSource struct {
Source string `json:"Source"`
ProviderName string `json:"ProviderName"`
}
// NewUser creates a new User model from a domain.User
func NewUser(src domain.User) User {
authSources := internal.Map(src.Authentications, func(authentication domain.UserAuthentication) UserAuthSource {
return UserAuthSource{
Source: string(authentication.Source),
ProviderName: authentication.ProviderName,
}
})
return User{
CreatedBy: src.CreatedBy,
UpdatedBy: src.UpdatedBy,
@@ -54,7 +40,8 @@ func NewUser(src domain.User) User {
UpdatedAt: src.UpdatedAt,
Identifier: string(src.Identifier),
Email: src.Email,
AuthSources: authSources,
Source: string(src.Source),
ProviderName: src.ProviderName,
IsAdmin: src.IsAdmin,
Firstname: src.Firstname,
Lastname: src.Lastname,

View File

@@ -121,25 +121,15 @@ func (c *StatisticsCollector) collectInterfaceData(ctx context.Context) {
"error", err)
continue
}
now := time.Now()
err = c.db.UpdateInterfaceStatus(ctx, in.Identifier,
func(i *domain.InterfaceStatus) (*domain.InterfaceStatus, error) {
td := domain.CalculateTrafficDelta(
string(in.Identifier),
i.UpdatedAt, now,
i.BytesTransmitted, physicalInterface.BytesUpload,
i.BytesReceived, physicalInterface.BytesDownload,
)
i.UpdatedAt = now
i.UpdatedAt = time.Now()
i.BytesReceived = physicalInterface.BytesDownload
i.BytesTransmitted = physicalInterface.BytesUpload
// Update prometheus metrics
go c.updateInterfaceMetrics(*i)
// Publish stats update event
c.bus.Publish(app.TopicInterfaceStatsUpdated, td)
return i, nil
})
if err != nil {
@@ -182,7 +172,6 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
slog.Warn("failed to fetch peers for data collection", "interface", in.Identifier, "error", err)
continue
}
now := time.Now()
for _, peer := range peers {
var connectionStateChanged bool
var newPeerStatus domain.PeerStatus
@@ -195,15 +184,8 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
lastHandshake = &peer.LastHandshake
}
td := domain.CalculateTrafficDelta(
string(peer.Identifier),
p.UpdatedAt, now,
p.BytesTransmitted, peer.BytesDownload,
p.BytesReceived, peer.BytesUpload,
)
// calculate if session was restarted
p.UpdatedAt = now
p.UpdatedAt = time.Now()
p.LastSessionStart = getSessionStartTime(*p, peer.BytesUpload, peer.BytesDownload,
lastHandshake)
p.BytesReceived = peer.BytesUpload // store bytes that where uploaded from the peer and received by the server
@@ -213,8 +195,7 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
p.CalcConnected()
if wasConnected != p.IsConnected {
slog.Debug("peer connection state changed",
"peer", peer.Identifier, "connected", p.IsConnected)
slog.Debug("peer connection state changed", "peer", peer.Identifier, "connected", p.IsConnected)
connectionStateChanged = true
newPeerStatus = *p // store new status for event publishing
}
@@ -222,9 +203,6 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
// Update prometheus metrics
go c.updatePeerMetrics(ctx, *p)
// Publish stats update event
c.bus.Publish(app.TopicPeerStatsUpdated, td)
return p, nil
})
if err != nil {

View File

@@ -374,7 +374,6 @@ func (m Manager) PrepareInterface(ctx context.Context) (*domain.Interface, error
SaveConfig: m.cfg.Advanced.ConfigStoragePath != "",
DisplayName: string(id),
Type: domain.InterfaceTypeServer,
CreateDefaultPeer: m.cfg.Core.CreateDefaultPeer,
DriverType: "",
Disabled: nil,
DisabledReason: "",
@@ -985,26 +984,7 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain
peer.InterfaceIdentifier = in.Identifier
peer.EndpointPublicKey = domain.NewConfigOption(in.PublicKey, true)
peer.AllowedIPsStr = domain.NewConfigOption(in.PeerDefAllowedIPsStr, true)
// split allowed IP's into interface addresses and extra allowed IP's
var interfaceAddresses []domain.Cidr
var extraAllowedIPs []domain.Cidr
for _, allowedIP := range p.AllowedIPs {
isHost := (allowedIP.IsV4() && allowedIP.NetLength == 32) || (!allowedIP.IsV4() && allowedIP.NetLength == 128)
isNetworkAddr := allowedIP.Addr == allowedIP.NetworkAddr().Addr
// Network addresses (e.g. 10.0.0.0/24) will always be extra allowed IP's.
// For IP addresses, such as 10.0.0.1/24, it is challenging to tell whether it is an interface address or
// an extra allowed IP, therefore we treat such addresses as interface addresses.
if !isHost && isNetworkAddr {
extraAllowedIPs = append(extraAllowedIPs, allowedIP)
} else {
interfaceAddresses = append(interfaceAddresses, allowedIP)
}
}
peer.Interface.Addresses = interfaceAddresses
peer.ExtraAllowedIPsStr = domain.CidrsToString(extraAllowedIPs)
peer.Interface.Addresses = p.AllowedIPs // use allowed IP's as the peer IP's TODO: Should this also match server interface address' prefix length?
peer.Interface.DnsStr = domain.NewConfigOption(in.PeerDefDnsStr, true)
peer.Interface.DnsSearchStr = domain.NewConfigOption(in.PeerDefDnsSearchStr, true)
peer.Interface.Mtu = domain.NewConfigOption(in.PeerDefMtu, true)

View File

@@ -1,94 +0,0 @@
package wireguard
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/h44z/wg-portal/internal/domain"
)
func TestImportPeer_AddressMapping(t *testing.T) {
tests := []struct {
name string
allowedIPs []string
expectedInterface []string
expectedExtraAllowed string
}{
{
name: "IPv4 host address",
allowedIPs: []string{"10.0.0.1/32"},
expectedInterface: []string{"10.0.0.1/32"},
expectedExtraAllowed: "",
},
{
name: "IPv6 host address",
allowedIPs: []string{"fd00::1/128"},
expectedInterface: []string{"fd00::1/128"},
expectedExtraAllowed: "",
},
{
name: "IPv4 network address",
allowedIPs: []string{"10.0.1.0/24"},
expectedInterface: []string{},
expectedExtraAllowed: "10.0.1.0/24",
},
{
name: "IPv4 normal address with mask",
allowedIPs: []string{"10.0.1.5/24"},
expectedInterface: []string{"10.0.1.5/24"},
expectedExtraAllowed: "",
},
{
name: "Mixed addresses",
allowedIPs: []string{
"10.0.0.1/32", "192.168.1.0/24", "172.16.0.5/24", "fd00::1/128", "fd00:1::/64",
},
expectedInterface: []string{"10.0.0.1/32", "172.16.0.5/24", "fd00::1/128"},
expectedExtraAllowed: "192.168.1.0/24,fd00:1::/64",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := &mockDB{}
m := Manager{
db: db,
}
iface := &domain.Interface{
Identifier: "wg0",
Type: domain.InterfaceTypeServer,
}
allowedIPs := make([]domain.Cidr, len(tt.allowedIPs))
for i, s := range tt.allowedIPs {
cidr, _ := domain.CidrFromString(s)
allowedIPs[i] = cidr
}
p := &domain.PhysicalPeer{
Identifier: "peer1",
KeyPair: domain.KeyPair{PublicKey: "peer1-public-key-is-long-enough"},
AllowedIPs: allowedIPs,
}
err := m.importPeer(context.Background(), iface, p)
assert.NoError(t, err)
savedPeer := db.savedPeers["peer1"]
assert.NotNil(t, savedPeer)
// Check interface addresses
actualInterface := make([]string, len(savedPeer.Interface.Addresses))
for i, addr := range savedPeer.Interface.Addresses {
actualInterface[i] = addr.String()
}
assert.ElementsMatch(t, tt.expectedInterface, actualInterface)
// Check extra allowed IPs
assert.Equal(t, tt.expectedExtraAllowed, savedPeer.ExtraAllowedIPsStr)
})
}
}

View File

@@ -35,10 +35,6 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti
continue // only create default peers for server interfaces
}
if !iface.CreateDefaultPeer {
continue // only create default peers if the interface flag is set
}
peerAlreadyCreated := slices.ContainsFunc(userPeers, func(peer domain.Peer) bool {
return peer.InterfaceIdentifier == iface.Identifier
})

View File

@@ -78,12 +78,7 @@ func (f *mockDB) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceId
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) {
if f.iface != nil {
return []domain.Interface{*f.iface}, nil
}
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
}
@@ -197,58 +192,3 @@ func TestCreatePeer_SetsIdentifier_FromPublicKey(t *testing.T) {
t.Fatalf("expected peer with identifier %q to be saved in DB", expectedId)
}
}
func TestCreateDefaultPeer_RespectsInterfaceFlag(t *testing.T) {
// Arrange
cfg := &config.Config{}
cfg.Core.CreateDefaultPeer = true
bus := &mockBus{}
ctrlMgr := &ControllerManager{
controllers: map[domain.InterfaceBackend]backendInstance{
config.LocalBackendName: {Implementation: &mockController{}},
},
}
db := &mockDB{
iface: &domain.Interface{
Identifier: "wg0",
Type: domain.InterfaceTypeServer,
CreateDefaultPeer: false, // Flag is disabled!
},
}
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: true})
// Act
err := m.CreateDefaultPeer(ctx, userId)
// Assert
if err != nil {
t.Fatalf("CreateDefaultPeer returned error: %v", err)
}
if len(db.savedPeers) != 0 {
t.Fatalf("expected no peers to be created because interface flag is false, but got %d", len(db.savedPeers))
}
// Now enable the flag and try again
db.iface.CreateDefaultPeer = true
err = m.CreateDefaultPeer(ctx, userId)
if err != nil {
t.Fatalf("CreateDefaultPeer returned error after enabling flag: %v", err)
}
if len(db.savedPeers) != 1 {
t.Fatalf("expected 1 peer to be created because interface flag is true, but got %d", len(db.savedPeers))
}
}

View File

@@ -10,12 +10,11 @@ type LoginProviderInfo struct {
}
type AuthenticatorUserInfo struct {
Identifier UserIdentifier
Email string
Firstname string
Lastname string
Phone string
Department string
IsAdmin bool
AdminInfoAvailable bool // true if the IsAdmin flag is valid
Identifier UserIdentifier
Email string
Firstname string
Lastname string
Phone string
Department string
IsAdmin bool
}

View File

@@ -14,7 +14,6 @@ const (
CtxSystemLdapSyncer = "_WG_SYS_LDAP_SYNCER_"
CtxSystemWgImporter = "_WG_SYS_WG_IMPORTER_"
CtxSystemV1Migrator = "_WG_SYS_V1_MIGRATOR_"
CtxSystemDBMigrator = "_WG_SYS_DB_MIGRATOR_"
)
type ContextUserInfo struct {

View File

@@ -53,13 +53,12 @@ type Interface struct {
SaveConfig bool // automatically persist config changes to the wgX.conf file
// WG Portal specific
DisplayName string // a nice display name/ description for the interface
Type InterfaceType // the interface type, either InterfaceTypeServer or InterfaceTypeClient
CreateDefaultPeer bool // if true, default peers will be created for this interface
Backend InterfaceBackend // the backend that is used to manage the interface (wgctrl, mikrotik, ...)
DriverType string // the interface driver type (linux, software, ...)
Disabled *time.Time `gorm:"index"` // flag that specifies if the interface is enabled (up) or not (down)
DisabledReason string // the reason why the interface has been disabled
DisplayName string // a nice display name/ description for the interface
Type InterfaceType // the interface type, either InterfaceTypeServer or InterfaceTypeClient
Backend InterfaceBackend // the backend that is used to manage the interface (wgctrl, mikrotik, ...)
DriverType string // the interface driver type (linux, software, ...)
Disabled *time.Time `gorm:"index"` // flag that specifies if the interface is enabled (up) or not (down)
DisabledReason string // the reason why the interface has been disabled
// Default settings for the peer, used for new peers, those settings will be published to ConfigOption options of
// the peer config

View File

@@ -61,25 +61,3 @@ func (r PingerResult) AverageRtt() time.Duration {
}
return total / time.Duration(len(r.Rtts))
}
type TrafficDelta struct {
EntityId string `json:"EntityId"` // Either peerId or interfaceId
BytesReceivedPerSecond uint64 `json:"BytesReceived"`
BytesTransmittedPerSecond uint64 `json:"BytesTransmitted"`
}
func CalculateTrafficDelta(id string, oldTime, newTime time.Time, oldTx, newTx, oldRx, newRx uint64) TrafficDelta {
timeDiff := uint64(newTime.Sub(oldTime).Seconds())
if timeDiff == 0 {
return TrafficDelta{
EntityId: id,
BytesReceivedPerSecond: 0,
BytesTransmittedPerSecond: 0,
}
}
return TrafficDelta{
EntityId: id,
BytesReceivedPerSecond: (newRx - oldRx) / timeDiff,
BytesTransmittedPerSecond: (newTx - oldTx) / timeDiff,
}
}

View File

@@ -25,14 +25,6 @@ type UserIdentifier string
type UserSource string
type UserAuthentication struct {
BaseModel
UserIdentifier UserIdentifier `gorm:"primaryKey;column:user_identifier"` // sAMAccountName, sub, etc.
Source UserSource `gorm:"primaryKey;column:source"`
ProviderName string `gorm:"primaryKey;column:provider_name"`
}
// User is the user model that gets linked to peer entries, by default an empty user model with only the email address is created
type User struct {
BaseModel
@@ -40,15 +32,10 @@ type User struct {
// required fields
Identifier UserIdentifier `gorm:"primaryKey;column:identifier"`
Email string `form:"email" binding:"required,email"`
Source UserSource // deprecated: moved to Authentications.Source
ProviderName string // deprecated: moved to Authentications.ProviderName
Source UserSource
ProviderName string
IsAdmin bool
// authentication sources
Authentications []UserAuthentication `gorm:"foreignKey:user_identifier"`
// synchronization behavior
PersistLocalChanges bool `gorm:"column:persist_local_changes"`
// optional fields
Firstname string `form:"firstname" binding:"omitempty"`
Lastname string `form:"lastname" binding:"omitempty"`
@@ -94,19 +81,15 @@ func (u *User) IsApiEnabled() bool {
}
func (u *User) CanChangePassword() error {
if slices.ContainsFunc(u.Authentications, func(e UserAuthentication) bool {
return e.Source == UserSourceDatabase
}) {
return nil // password can be changed for database users
if u.Source == UserSourceDatabase {
return nil
}
return errors.New("password change only allowed for database source")
}
func (u *User) HasWeakPassword(minLength int) error {
if !slices.ContainsFunc(u.Authentications, func(e UserAuthentication) bool {
return e.Source == UserSourceDatabase
}) {
if u.Source != UserSourceDatabase {
return nil // password is not required for non-database users, so no check needed
}
@@ -122,16 +105,13 @@ func (u *User) HasWeakPassword(minLength int) error {
}
func (u *User) EditAllowed(new *User) error {
if len(u.Authentications) == 1 && u.Authentications[0].Source == UserSourceDatabase {
return nil // database-only users can be edited always
}
if new.PersistLocalChanges {
return nil // if changes will be persisted locally, they can be edited always
if u.Source == UserSourceDatabase {
return nil
}
// for users which are not database users, only the notes field and the disabled flag can be updated
updateOk := u.Identifier == new.Identifier
updateOk = updateOk && u.Source == new.Source
updateOk = updateOk && u.IsAdmin == new.IsAdmin
updateOk = updateOk && u.Email == new.Email
updateOk = updateOk && u.Firstname == new.Firstname
@@ -140,7 +120,7 @@ func (u *User) EditAllowed(new *User) error {
updateOk = updateOk && u.Department == new.Department
if !updateOk {
return errors.New("edit only allowed for reserved fields")
return errors.New("edit only allowed for database source")
}
return nil
@@ -151,10 +131,8 @@ func (u *User) DeleteAllowed() error {
}
func (u *User) CheckPassword(password string) error {
if !slices.ContainsFunc(u.Authentications, func(e UserAuthentication) bool {
return e.Source == UserSourceDatabase
}) {
return errors.New("invalid user source") // password can only be checked for database users
if u.Source != UserSourceDatabase {
return errors.New("invalid user source")
}
if u.IsDisabled() {
@@ -202,24 +180,9 @@ func (u *User) HashPassword() error {
return nil
}
func (u *User) CopyCalculatedAttributes(src *User, withAuthentications bool) {
func (u *User) CopyCalculatedAttributes(src *User) {
u.BaseModel = src.BaseModel
u.LinkedPeerCount = src.LinkedPeerCount
if withAuthentications {
u.Authentications = src.Authentications
u.WebAuthnId = src.WebAuthnId
u.WebAuthnCredentialList = src.WebAuthnCredentialList
}
}
// MergeAuthSources merges the given authentication sources with the existing ones.
// Already existing sources are not overwritten, nor will be added any duplicates.
func (u *User) MergeAuthSources(extSources ...UserAuthentication) {
for _, src := range extSources {
if !slices.Contains(u.Authentications, src) {
u.Authentications = append(u.Authentications, src)
}
}
}
// DisplayName returns the display name of the user.

View File

@@ -35,25 +35,19 @@ func TestUser_IsApiEnabled(t *testing.T) {
}
func TestUser_CanChangePassword(t *testing.T) {
user := &User{Authentications: []UserAuthentication{{Source: UserSourceDatabase}}}
user := &User{Source: UserSourceDatabase}
assert.NoError(t, user.CanChangePassword())
user.Authentications = []UserAuthentication{{Source: UserSourceLdap}}
user.Source = UserSourceLdap
assert.Error(t, user.CanChangePassword())
user.Authentications = []UserAuthentication{{Source: UserSourceOauth}}
user.Source = UserSourceOauth
assert.Error(t, user.CanChangePassword())
user.Authentications = []UserAuthentication{{Source: UserSourceLdap}, {Source: UserSourceDatabase}}
assert.NoError(t, user.CanChangePassword())
user.Authentications = []UserAuthentication{{Source: UserSourceOauth}, {Source: UserSourceDatabase}}
assert.NoError(t, user.CanChangePassword())
}
func TestUser_EditAllowed(t *testing.T) {
user := &User{Authentications: []UserAuthentication{{Source: UserSourceDatabase}}}
newUser := &User{Authentications: []UserAuthentication{{Source: UserSourceDatabase}}}
user := &User{Source: UserSourceDatabase}
newUser := &User{Source: UserSourceDatabase}
assert.NoError(t, user.EditAllowed(newUser))
newUser.Notes = "notes can be changed"
@@ -65,8 +59,8 @@ func TestUser_EditAllowed(t *testing.T) {
newUser.Lastname = "lastname or other fields can be changed"
assert.NoError(t, user.EditAllowed(newUser))
user.Authentications = []UserAuthentication{{Source: UserSourceLdap}}
newUser.Authentications = []UserAuthentication{{Source: UserSourceLdap}}
user.Source = UserSourceLdap
newUser.Source = UserSourceLdap
newUser.Disabled = nil
newUser.Lastname = ""
newUser.Notes = "notes can be changed"
@@ -78,8 +72,8 @@ func TestUser_EditAllowed(t *testing.T) {
newUser.Lastname = "lastname or other fields can not be changed"
assert.Error(t, user.EditAllowed(newUser))
user.Authentications = []UserAuthentication{{Source: UserSourceOauth}}
newUser.Authentications = []UserAuthentication{{Source: UserSourceOauth}}
user.Source = UserSourceOauth
newUser.Source = UserSourceOauth
newUser.Disabled = nil
newUser.Lastname = ""
newUser.Notes = "notes can be changed"
@@ -90,20 +84,6 @@ func TestUser_EditAllowed(t *testing.T) {
newUser.Lastname = "lastname or other fields can not be changed"
assert.Error(t, user.EditAllowed(newUser))
user.Authentications = []UserAuthentication{{Source: UserSourceOauth}, {Source: UserSourceDatabase}}
newUser.Authentications = []UserAuthentication{{Source: UserSourceOauth}, {Source: UserSourceDatabase}}
newUser.PersistLocalChanges = true
newUser.Disabled = nil
newUser.Lastname = ""
newUser.Notes = "notes can be changed"
assert.NoError(t, user.EditAllowed(newUser))
newUser.Disabled = &time.Time{}
assert.NoError(t, user.EditAllowed(newUser))
newUser.Lastname = "lastname or other fields can be changed"
assert.NoError(t, user.EditAllowed(newUser))
}
func TestUser_DeleteAllowed(t *testing.T) {
@@ -115,15 +95,13 @@ func TestUser_CheckPassword(t *testing.T) {
password := "password"
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
user := &User{
Authentications: []UserAuthentication{{Source: UserSourceDatabase}}, Password: PrivateString(hashedPassword),
}
user := &User{Source: UserSourceDatabase, Password: PrivateString(hashedPassword)}
assert.NoError(t, user.CheckPassword(password))
user.Password = ""
assert.Error(t, user.CheckPassword(password))
user.Authentications = []UserAuthentication{{Source: UserSourceLdap}}
user.Source = UserSourceLdap
assert.Error(t, user.CheckPassword(password))
}

View File

@@ -168,12 +168,3 @@ func BoolToFloat64(b bool) float64 {
}
return 0.0
}
// Map applies the given function to each element of the given slice and returns the resulting slice
func Map[T, V any](ts []T, fn func(T) V) []V {
result := make([]V, len(ts))
for i, t := range ts {
result[i] = fn(t)
}
return result
}

View File

@@ -81,12 +81,12 @@ nav:
- Reverse Proxy (HTTPS): documentation/getting-started/reverse-proxy.md
- Configuration:
- Overview: documentation/configuration/overview.md
- Mail templates: documentation/configuration/mail-templates.md
- Examples: documentation/configuration/examples.md
- Usage:
- General: documentation/usage/general.md
- Backends: documentation/usage/backends.md
- Authentication: documentation/usage/authentication.md
- User Management: documentation/usage/user-sync.md
- LDAP: documentation/usage/ldap.md
- Security: documentation/usage/security.md
- Webhooks: documentation/usage/webhooks.md
- Mail Templates: documentation/usage/mail-templates.md

View File

@@ -7,13 +7,11 @@ After=network.target
Type=simple
User=root
Group=root
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
Restart=on-failure
RestartSec=10
WorkingDirectory=/opt/wg-portal
Environment=WG_PORTAL_CONFIG=/opt/wg-portal/config.yml
ExecStart=/opt/wg-portal/wg-portal-amd64
[Install]