Merge branch 'master' into stable

This commit is contained in:
Christoph Haas
2026-06-05 20:59:17 +02:00
85 changed files with 4623 additions and 901 deletions

View File

@@ -1,7 +1,8 @@
# Go parameters # Go parameters
GOCMD=go GOCMD=go
GOVERSION=1.25
MODULENAME=github.com/h44z/wg-portal MODULENAME=github.com/h44z/wg-portal
GOFILES:=$(shell go list ./... | grep -v /vendor/) GOFILES=$(shell go list ./... | grep -v /vendor/)
BUILDDIR=dist BUILDDIR=dist
BINARIES=$(subst cmd/,,$(wildcard cmd/*)) BINARIES=$(subst cmd/,,$(wildcard cmd/*))
IMAGE=h44z/wg-portal IMAGE=h44z/wg-portal
@@ -51,6 +52,11 @@ format:
.PHONY: test .PHONY: test
test: test-vet test-race test: test-vet test-race
#> test-in-docker: Run tests in Docker (for non-Linux environments e.g. MacOS)
.PHONY: test-in-docker
test-in-docker:
docker run --rm -u $(shell id -u):$(shell id -g) -e HOME=/tmp -v $(PWD):/app -w /app golang:$(GOVERSION) make test
#< test-vet: Static code analysis #< test-vet: Static code analysis
.PHONY: test-vet .PHONY: test-vet
test-vet: build-dependencies test-vet: build-dependencies

View File

@@ -135,7 +135,8 @@ func main() {
apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers) apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers)
apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard) apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard)
apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth) apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth)
apiV0EndpointWebsocket := handlersV0.NewWebsocketEndpoint(cfg, apiV0Auth, eventBus) apiV0EndpointWebsocket := handlersV0.NewWebsocketEndpoint(cfg, apiV0Auth, eventBus, apiV0BackendPeers)
apiV0EndpointWebsocket.StartBackgroundJobs(ctx)
apiFrontend := handlersV0.NewRestApi(apiV0Session, apiFrontend := handlersV0.NewRestApi(apiV0Session,
apiV0EndpointAuth, apiV0EndpointAuth,

View File

@@ -6,8 +6,9 @@ advanced:
core: core:
admin_user: test@test.de admin_user: test@test.de
admin_password: secret admin_password: secret
create_default_peer: true create_default_peer_on_login: true
create_default_peer_on_creation: false create_default_peer_on_user_creation: false
create_default_peer_on_interface_creation: false
web: web:
external_url: http://localhost:8888 external_url: http://localhost:8888
@@ -46,7 +47,10 @@ auth:
extra_scopes: extra_scopes:
- https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile - https://www.googleapis.com/auth/userinfo.profile
use_pkce: true
pkce_method: S256
registration_enabled: true registration_enabled: true
logout_idp_session: true
- id: oidc2 - id: oidc2
provider_name: google2 provider_name: google2
display_name: Login with</br>Google2 display_name: Login with</br>Google2
@@ -57,6 +61,7 @@ auth:
- https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile - https://www.googleapis.com/auth/userinfo.profile
registration_enabled: true registration_enabled: true
logout_idp_session: true
oauth: oauth:
- id: google_plain_oauth - id: google_plain_oauth
provider_name: google3 provider_name: google3
@@ -76,6 +81,7 @@ auth:
user_identifier: sub user_identifier: sub
is_admin: this-attribute-must-be-true is_admin: this-attribute-must-be-true
registration_enabled: true registration_enabled: true
use_pkce: false
- id: google_plain_oauth_with_groups - id: google_plain_oauth_with_groups
provider_name: google4 provider_name: google4
display_name: Login with</br>Google4 display_name: Login with</br>Google4

View File

@@ -8,7 +8,7 @@ core:
admin_password: password admin_password: password
admin_api_token: super-s3cr3t-api-token-or-a-UUID admin_api_token: super-s3cr3t-api-token-or-a-UUID
import_existing: false import_existing: false
create_default_peer: true create_default_peer_on_login: true
self_provisioning_allowed: true self_provisioning_allowed: true
backend: backend:
@@ -144,6 +144,9 @@ auth:
extra_scopes: extra_scopes:
- https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile - https://www.googleapis.com/auth/userinfo.profile
allowed_user_groups:
- the-admin-group
- vpn-users
field_map: field_map:
user_identifier: sub user_identifier: sub
email: email email: email
@@ -201,6 +204,9 @@ auth:
- email - email
- profile - profile
- i-want-some-groups - i-want-some-groups
allowed_user_groups:
- admin-group-name
- vpn-users
field_map: field_map:
email: email email: email
firstname: name firstname: name

View File

@@ -155,17 +155,33 @@ More advanced options are found in the subsequent `Advanced` section.
- **Environment Variable:** `WG_PORTAL_CORE_EDITABLE_KEYS` - **Environment Variable:** `WG_PORTAL_CORE_EDITABLE_KEYS`
- **Description:** Allow editing of WireGuard key-pairs directly in the UI. - **Description:** Allow editing of WireGuard key-pairs directly in the UI.
### `create_default_peer` ### `create_default_peer` (deprecated)
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER`
- **Description:** **DEPRECATED** in favor of [create_default_peer_on_login](#create_default_peer_on_login). If set to `true`, this option is equivalent to enabling `create_default_peer_on_login`. It will be removed in a future release (2.4).
### `create_default_peer_on_creation` (deprecated)
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION`
- **Description:** **DEPRECATED** in favor of [create_default_peer_on_user_creation](#create_default_peer_on_user_creation) and [create_default_peer_on_interface_creation](#create_default_peer_on_interface_creation). If set to `true`, both of those options are enabled. It will be removed in a future release (2.4).
### `create_default_peer_on_login`
- **Default:** `false` - **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER` - **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. - **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). - **Important:** This option is only effective for interfaces where the "Create default peer" flag is set (via the UI).
### `create_default_peer_on_creation` ### `create_default_peer_on_user_creation`
- **Default:** `false` - **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION` - **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_USER_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. - **Description:** If a new user is created (e.g., through LDAP sync or registration) 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. - **Important:** This option is only effective for interfaces where the "Create default peer" flag is set (via the UI).
### `create_default_peer_on_interface_creation`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_INTERFACE_CREATION`
- **Description:** When a new server interface is created with the "Create default peer" flag set, automatically create a default WireGuard peer on that interface for every existing user who does not yet have a peer on it.
- **Important:** This option is only effective for interfaces where the "Create default peer" flag is set (via the UI).
### `re_enable_peer_after_user_enable` ### `re_enable_peer_after_user_enable`
- **Default:** `true` - **Default:** `true`
@@ -536,6 +552,7 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
#### `provider_name` #### `provider_name`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A **unique** name for this provider. Must not conflict with other providers. - **Description:** A **unique** name for this provider. Must not conflict with other providers.
This name is used to derive the callback URL for the OIDC provider: `<external_url>/api/v0/auth/login/<provider_name>/callback`.
#### `display_name` #### `display_name`
- **Default:** *(empty)* - **Default:** *(empty)*
@@ -561,6 +578,10 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups. - **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups.
#### `allowed_user_groups`
- **Default:** *(empty)*
- **Description:** A list of allowlisted user groups. If configured, at least one entry in the mapped `user_groups` claim must match one of these values.
#### `field_map` #### `field_map`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** Maps OIDC claims to WireGuard Portal user fields. - **Description:** Maps OIDC claims to WireGuard Portal user fields.
@@ -582,6 +603,7 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- **Description:** WgPortal can grant a user admin rights by matching the value of the `is_admin` claim against a regular expression. Alternatively, a regular expression can be used to check if a user is member of a specific group listed in the `user_group` claim. The regular expressions are defined in `admin_value_regex` and `admin_group_regex`. - **Description:** WgPortal can grant a user admin rights by matching the value of the `is_admin` claim against a regular expression. Alternatively, a regular expression can be used to check if a user is member of a specific group listed in the `user_group` claim. The regular expressions are defined in `admin_value_regex` and `admin_group_regex`.
- `admin_value_regex`: A regular expression to match the `is_admin` claim. By default, this expression matches the string "true" (`^true$`). - `admin_value_regex`: A regular expression to match the `is_admin` claim. By default, this expression matches the string "true" (`^true$`).
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex. - `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
- To identify which claim to match against, set log_level: debug and reload the config. Log in with the intended admin account and inspect the logs for the OIDC user info payload. If the required claim is missing it must be added by the OIDC provider. If it is present, use its value as the pattern for admin_group_regex.
#### `registration_enabled` #### `registration_enabled`
- **Default:** `false` - **Default:** `false`
@@ -596,6 +618,18 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging). - **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues. - **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
#### `use_pkce`
- **Default:** `true`
- **Description:** If `true`, Proof Key for Code Exchange (PKCE) is used for the OIDC authorization code flow. A fresh `code_verifier` is generated per login request, the matching `code_challenge` is sent with the authorization request, and the `code_verifier` is included in the token exchange. Set to `false` only for providers that do not support PKCE.
#### `pkce_method`
- **Default:** `S256`
- **Description:** PKCE challenge method to use when `use_pkce` is enabled. Supported values are `S256` and `plain`. `S256` is recommended; use `plain` only for providers that explicitly require it.
#### `logout_idp_session`
- **Default:** `true`
- **Description:** If `true` (default), WireGuard Portal will redirect the user to the OIDC provider's `end_session_endpoint` after local logout, terminating the session at the IdP as well. Set to `false` to only invalidate the local WireGuard Portal session without touching the IdP session.
--- ---
### OAuth ### OAuth
@@ -606,6 +640,7 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
#### `provider_name` #### `provider_name`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A **unique** name for this provider. Must not conflict with other providers. - **Description:** A **unique** name for this provider. Must not conflict with other providers.
This name is used to derive the callback URL for the OAuth provider: `<external_url>/api/v0/auth/login/<provider_name>/callback`.
#### `display_name` #### `display_name`
- **Default:** *(empty)* - **Default:** *(empty)*
@@ -639,6 +674,10 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups. - **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups.
#### `allowed_user_groups`
- **Default:** *(empty)*
- **Description:** A list of allowlisted user groups. If configured, at least one entry in the mapped `user_groups` claim must match one of these values.
#### `field_map` #### `field_map`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** Maps OAuth attributes to WireGuard Portal fields. - **Description:** Maps OAuth attributes to WireGuard Portal fields.
@@ -674,6 +713,14 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging). - **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues. - **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
#### `use_pkce`
- **Default:** `true`
- **Description:** If `true`, Proof Key for Code Exchange (PKCE) is used for the OIDC authorization code flow. A fresh `code_verifier` is generated per login request, the matching `code_challenge` is sent with the authorization request, and the `code_verifier` is included in the token exchange. Set to `false` only for providers that do not support PKCE.
#### `pkce_method`
- **Default:** `S256`
- **Description:** PKCE challenge method to use when `use_pkce` is enabled. Supported values are `S256` and `plain`. `S256` is recommended; use `plain` only for providers that explicitly require it.
--- ---
### LDAP ### LDAP

View File

@@ -51,13 +51,31 @@ 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/). 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. Refer to [Systemd Service Setup](#systemd-service-setup) for instructions.
## Systemd Service Setup ## Systemd Integration
> **Note:** To run WireGuard Portal as systemd service, you need to download the binary for your architecture beforehand. > **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 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`. > The configuration file is expected to be located at `/opt/wg-portal/config.yml`.
### Limit Systemd-Networkd Management Scope
If you are using `systemd-networkd` to manage the rest of your network
configuration, you will need to ensure it doesn't remove routing policy
created by `wg-portal` when it restarts:
```shell
sudo mkdir --parents /etc/systemd/networkd.conf.d/
sudo tee --append /etc/systemd/networkd.conf.d/foreign-routing.conf <<EOF
[Network]
ManageForeignRoutingPolicyRules=no
EOF
sudo systemctl restart systemd-networkd.service
sudo systemctl status systemd-networkd.service
```
### Wireguard Portal Service Setup
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`: 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 ```shell

View File

@@ -51,6 +51,15 @@ To add OIDC or OAuth2 authentication to WireGuard Portal, create a Client-ID and
configure a new authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file. 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). Make sure that each configured provider has a unique `provider_name` property set. Samples can be seen [here](../configuration/examples.md).
When registering the OAuth2 or OIDC application with your provider, configure the callback/redirect URL as follows:
```text
<external_url>/api/v0/auth/login/<provider_name>/callback
```
Replace `<external_url>` with the value configured in [`external_url`](../configuration/overview.md#external_url) and
`<provider_name>` with the exact `provider_name` from the matching OAuth2 or OIDC provider configuration.
#### Limiting Login to Specific Domains #### Limiting Login to Specific Domains
You can limit the login to specific domains by setting the `allowed_domains` property for OAuth2 or OIDC providers. You can limit the login to specific domains by setting the `allowed_domains` property for OAuth2 or OIDC providers.
@@ -66,6 +75,40 @@ auth:
- "outlook.com" - "outlook.com"
``` ```
#### Limiting Login to Specific User Groups
You can limit the login to specific user groups by setting the `allowed_user_groups` property for OAuth2 or OIDC providers.
If this property is not empty, the user's `user_groups` claim must contain at least one matching group.
To use this feature, ensure your group claim is mapped via `field_map.user_groups`.
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
allowed_user_groups:
- "wg-users"
- "wg-admins"
field_map:
user_groups: "groups"
```
If `allowed_user_groups` is configured and the authenticated user has no matching group in `user_groups`, login is denied.
Minimal deny-by-group example:
```yaml
auth:
oauth:
- provider_name: "oauth1"
# ... other settings
allowed_user_groups:
- "vpn-users"
field_map:
user_groups: "groups"
```
#### Limit Login to Existing Users #### 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. You can limit the login to existing users only by setting the `registration_enabled` property to `false` for OAuth2 or OIDC providers.

View File

@@ -61,7 +61,7 @@ backend:
> :warning: The pfSense backend is currently **alpha**. Only basic interface and peer CRUD are supported. Traffic statistics (rx/tx, last handshake) are not exposed by the pfSense REST API and will show as empty. > :warning: The pfSense backend is currently **alpha**. Only basic interface and peer CRUD are supported. Traffic statistics (rx/tx, last handshake) are not exposed by the pfSense REST API and will show as empty.
The pfSense backend talks to the pfSense REST API (pfSense Plus / CE with the REST API package installed). Point the backend at the appliance hostname without appending `/api/v2` — the portal appends `/api/v2` automatically. The pfSense backend talks to the pfSense REST API (pfSense Plus / CE with the REST API package installed). Point the backend at the appliance hostname without appending `/api/v2` — the portal appends `/api/v2` automatically. wg-portal is developed for and tested against REST API v2.8.0.
### Prerequisites on pfSense: ### Prerequisites on pfSense:
- pfSense with the REST API package enabled (`System -> API`) and WireGuard configured. - pfSense with the REST API package enabled (`System -> API`) and WireGuard configured.

View File

@@ -8,6 +8,23 @@ To enable encryption, set the [`encryption_passphrase`](../configuration/overvie
> :warning: Important: Once encryption is enabled, it cannot be disabled, and the passphrase cannot be changed! > :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. > Only new or updated records will be encrypted; existing data remains in plaintext until its next modified.
## External Identity Provider Data Sanitization
When users authenticate via LDAP, OIDC, or OAuth, WireGuard Portal sanitizes the field values received from the provider before storing them. This protects against several classes of attack that a compromised or misconfigured identity provider could introduce:
- **Unsafe control characters** — Unicode control and format characters, null bytes, and invalid UTF-8 bytes are stripped from external profile fields before they reach the Vue.js UI or email templates.
- **Email header injection** — carriage return and line feed characters in email fields are rejected entirely, and email fields must parse as plain email addresses.
- **Log injection** — unsafe control and format characters are stripped from all external profile fields and from sanitization log context.
- **Denial of service via oversized fields** — field lengths are capped (e.g., 256 runes for identifiers, 254 characters for email addresses).
- **Reserved identifier collision** — reserved user identifiers such as `"all"`, `"new"`, `"id"`, and internal system user identifiers are rejected.
- **Unsafe authorization groups** — OIDC/OAuth group claims are sanitized before group-based checks; groups changed by control/format stripping or truncation are dropped rather than repaired into allowed/admin matches.
Sanitization is always enabled and cannot be disabled.
When sanitization modifies or clears a field value, a `WARN` log entry is emitted with the provider name, provider type, and field name — but never the raw or sanitized value, to avoid leaking sensitive data into logs. This makes it straightforward to detect and investigate potentially malicious or misconfigured providers.
---
## UI and API Access ## UI and API Access
WireGuard Portal provides a web UI and a REST API for user interaction. It is important to secure these interfaces to prevent unauthorized access and data breaches. WireGuard Portal provides a web UI and a REST API for user interaction. It is important to secure these interfaces to prevent unauthorized access and data breaches.

View File

@@ -23,15 +23,15 @@
"is-ip": "^5.0.1", "is-ip": "^5.0.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"vue": "^3.5.31", "vue": "^3.5.32",
"vue-i18n": "^11.3.0", "vue-i18n": "^11.3.2",
"vue-prism-component": "github:h44z/vue-prism-component", "vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^5.0.4" "vue-router": "^5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.6",
"sass-embedded": "^1.98.0", "sass-embedded": "^1.99.0",
"vite": "^8.0.3" "vite": "^8.0.8"
} }
}, },
"node_modules/@babel/generator": { "node_modules/@babel/generator": {
@@ -104,38 +104,35 @@
"license": "(Apache-2.0 AND BSD-3-Clause)" "license": "(Apache-2.0 AND BSD-3-Clause)"
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.9.1", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.0", "@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.9.1", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.0", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
@@ -159,14 +156,14 @@
} }
}, },
"node_modules/@intlify/core-base": { "node_modules/@intlify/core-base": {
"version": "11.3.0", "version": "11.3.2",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.3.0.tgz", "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.3.2.tgz",
"integrity": "sha512-NNX5jIwF4TJBe7RtSKDMOA6JD9mp2mRcBHAwt2X+Q8PvnZub0yj5YYXlFu2AcESdgQpEv/5Yx2uOCV/yh7YkZg==", "integrity": "sha512-cgsUaV/dyD6aS49UPgerIblrWeXAZHNaDWqm4LujOGC7IafSyhghGXEiSVvuDYaDPiQTP+tSFSTM1HIu7Yp1nA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/devtools-types": "11.3.0", "@intlify/devtools-types": "11.3.2",
"@intlify/message-compiler": "11.3.0", "@intlify/message-compiler": "11.3.2",
"@intlify/shared": "11.3.0" "@intlify/shared": "11.3.2"
}, },
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
@@ -176,13 +173,13 @@
} }
}, },
"node_modules/@intlify/devtools-types": { "node_modules/@intlify/devtools-types": {
"version": "11.3.0", "version": "11.3.2",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.3.0.tgz", "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.3.2.tgz",
"integrity": "sha512-G9CNL4WpANWVdUjubOIIS7/D2j/0j+1KJmhBJxHilWNKr9mmt3IjFV3Hq4JoBP23uOoC5ynxz/FHZ42M+YxfGw==", "integrity": "sha512-q96G2ZZw0FNoXzejbjIf9dbfgz1xyYBZu6ZT4b5TE/55j8d1O9X5jv0k+U+L3fVe7uebPcqRQFD0ffm30i5mJA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/core-base": "11.3.0", "@intlify/core-base": "11.3.2",
"@intlify/shared": "11.3.0" "@intlify/shared": "11.3.2"
}, },
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
@@ -192,12 +189,12 @@
} }
}, },
"node_modules/@intlify/message-compiler": { "node_modules/@intlify/message-compiler": {
"version": "11.3.0", "version": "11.3.2",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.3.0.tgz", "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.3.2.tgz",
"integrity": "sha512-RAJp3TMsqohg/Wa7bVF3cChRhecSYBLrTCQSj7j0UtWVFLP+6iEJoE2zb7GU5fp+fmG5kCbUdzhmlAUCWXiUJw==", "integrity": "sha512-d/awyHUkNSaGPxBxT/qlUpfRizxHX9dt55CnW03xx5p1KmMyfYHKupCnvzINX+Na8JR8LAR7y32lPKjoeQGmzA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/shared": "11.3.0", "@intlify/shared": "11.3.2",
"source-map-js": "^1.0.2" "source-map-js": "^1.0.2"
}, },
"engines": { "engines": {
@@ -208,9 +205,9 @@
} }
}, },
"node_modules/@intlify/shared": { "node_modules/@intlify/shared": {
"version": "11.3.0", "version": "11.3.2",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.3.0.tgz", "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.3.2.tgz",
"integrity": "sha512-LC6P/uay7rXL5zZ5+5iRJfLs/iUN8apu9tm8YqQVmW3Uq3X4A0dOFUIDuAmB7gAC29wTHOS3EiN/IosNSz0eNQ==", "integrity": "sha512-x66fjdH6i+lNYPae5URSQGTjBL68Av6hi09jvC5Ci96iTkwfqrPhCj46aylQZmgMaG89rOZCIKqS7ApC8ZDVjg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
@@ -274,9 +271,9 @@
} }
}, },
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.2", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -293,9 +290,9 @@
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.122.0", "version": "0.124.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -641,9 +638,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -658,9 +655,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -675,9 +672,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -692,9 +689,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -709,9 +706,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -726,9 +723,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -746,9 +743,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -766,9 +763,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -786,9 +783,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -806,9 +803,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -826,9 +823,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -846,9 +843,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -863,9 +860,9 @@
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": { "node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
@@ -873,16 +870,18 @@
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1" "@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@napi-rs/wasm-runtime": "^1.1.3"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -897,9 +896,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -914,9 +913,9 @@
} }
}, },
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.2", "version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
"integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -938,13 +937,13 @@
} }
}, },
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "6.0.5", "version": "6.0.6",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
"integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@rolldown/pluginutils": "1.0.0-rc.2" "@rolldown/pluginutils": "1.0.0-rc.13"
}, },
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
@@ -996,39 +995,39 @@
} }
}, },
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.31", "version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.31.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz",
"integrity": "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==", "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.2", "@babel/parser": "^7.29.2",
"@vue/shared": "3.5.31", "@vue/shared": "3.5.32",
"entities": "^7.0.1", "entities": "^7.0.1",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
} }
}, },
"node_modules/@vue/compiler-dom": { "node_modules/@vue/compiler-dom": {
"version": "3.5.31", "version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.31.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz",
"integrity": "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==", "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-core": "3.5.31", "@vue/compiler-core": "3.5.32",
"@vue/shared": "3.5.31" "@vue/shared": "3.5.32"
} }
}, },
"node_modules/@vue/compiler-sfc": { "node_modules/@vue/compiler-sfc": {
"version": "3.5.31", "version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.31.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
"integrity": "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==", "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.2", "@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.31", "@vue/compiler-core": "3.5.32",
"@vue/compiler-dom": "3.5.31", "@vue/compiler-dom": "3.5.32",
"@vue/compiler-ssr": "3.5.31", "@vue/compiler-ssr": "3.5.32",
"@vue/shared": "3.5.31", "@vue/shared": "3.5.32",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"postcss": "^8.5.8", "postcss": "^8.5.8",
@@ -1036,13 +1035,13 @@
} }
}, },
"node_modules/@vue/compiler-ssr": { "node_modules/@vue/compiler-ssr": {
"version": "3.5.31", "version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.31.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz",
"integrity": "sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==", "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.31", "@vue/compiler-dom": "3.5.32",
"@vue/shared": "3.5.31" "@vue/shared": "3.5.32"
} }
}, },
"node_modules/@vue/devtools-api": { "node_modules/@vue/devtools-api": {
@@ -1079,53 +1078,53 @@
} }
}, },
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.31", "version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.31.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz",
"integrity": "sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==", "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/shared": "3.5.31" "@vue/shared": "3.5.32"
} }
}, },
"node_modules/@vue/runtime-core": { "node_modules/@vue/runtime-core": {
"version": "3.5.31", "version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.31.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz",
"integrity": "sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==", "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.31", "@vue/reactivity": "3.5.32",
"@vue/shared": "3.5.31" "@vue/shared": "3.5.32"
} }
}, },
"node_modules/@vue/runtime-dom": { "node_modules/@vue/runtime-dom": {
"version": "3.5.31", "version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.31.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz",
"integrity": "sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==", "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.31", "@vue/reactivity": "3.5.32",
"@vue/runtime-core": "3.5.31", "@vue/runtime-core": "3.5.32",
"@vue/shared": "3.5.31", "@vue/shared": "3.5.32",
"csstype": "^3.2.3" "csstype": "^3.2.3"
} }
}, },
"node_modules/@vue/server-renderer": { "node_modules/@vue/server-renderer": {
"version": "3.5.31", "version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.31.tgz", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz",
"integrity": "sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==", "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-ssr": "3.5.31", "@vue/compiler-ssr": "3.5.32",
"@vue/shared": "3.5.31" "@vue/shared": "3.5.32"
}, },
"peerDependencies": { "peerDependencies": {
"vue": "3.5.31" "vue": "3.5.32"
} }
}, },
"node_modules/@vue/shared": { "node_modules/@vue/shared": {
"version": "3.5.31", "version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.31.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz",
"integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==", "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/acorn": { "node_modules/acorn": {
@@ -2067,14 +2066,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.122.0", "@oxc-project/types": "=0.124.0",
"@rolldown/pluginutils": "1.0.0-rc.12" "@rolldown/pluginutils": "1.0.0-rc.15"
}, },
"bin": { "bin": {
"rolldown": "bin/cli.mjs" "rolldown": "bin/cli.mjs"
@@ -2083,27 +2082,27 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-android-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.15",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
} }
}, },
"node_modules/rolldown/node_modules/@rolldown/pluginutils": { "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.12", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -2118,9 +2117,9 @@
} }
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz",
"integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -2140,9 +2139,9 @@
} }
}, },
"node_modules/sass-embedded": { "node_modules/sass-embedded": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.99.0.tgz",
"integrity": "sha512-Do7u6iRb6K+lrllcTkB1BXcHwOxcKe3rEfOF/GcCLE2w3WpddakRAosJOHFUR37DpsvimQXEt5abs3NzUjEIqg==", "integrity": "sha512-gF/juR1aX02lZHkvwxdF80SapkQeg2fetoDF6gIQkNbSw5YEUFspMkyGTjPjgZSgIHuZpy+Wz4PlebKnLXMjdg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2161,30 +2160,30 @@
"node": ">=16.0.0" "node": ">=16.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"sass-embedded-all-unknown": "1.98.0", "sass-embedded-all-unknown": "1.99.0",
"sass-embedded-android-arm": "1.98.0", "sass-embedded-android-arm": "1.99.0",
"sass-embedded-android-arm64": "1.98.0", "sass-embedded-android-arm64": "1.99.0",
"sass-embedded-android-riscv64": "1.98.0", "sass-embedded-android-riscv64": "1.99.0",
"sass-embedded-android-x64": "1.98.0", "sass-embedded-android-x64": "1.99.0",
"sass-embedded-darwin-arm64": "1.98.0", "sass-embedded-darwin-arm64": "1.99.0",
"sass-embedded-darwin-x64": "1.98.0", "sass-embedded-darwin-x64": "1.99.0",
"sass-embedded-linux-arm": "1.98.0", "sass-embedded-linux-arm": "1.99.0",
"sass-embedded-linux-arm64": "1.98.0", "sass-embedded-linux-arm64": "1.99.0",
"sass-embedded-linux-musl-arm": "1.98.0", "sass-embedded-linux-musl-arm": "1.99.0",
"sass-embedded-linux-musl-arm64": "1.98.0", "sass-embedded-linux-musl-arm64": "1.99.0",
"sass-embedded-linux-musl-riscv64": "1.98.0", "sass-embedded-linux-musl-riscv64": "1.99.0",
"sass-embedded-linux-musl-x64": "1.98.0", "sass-embedded-linux-musl-x64": "1.99.0",
"sass-embedded-linux-riscv64": "1.98.0", "sass-embedded-linux-riscv64": "1.99.0",
"sass-embedded-linux-x64": "1.98.0", "sass-embedded-linux-x64": "1.99.0",
"sass-embedded-unknown-all": "1.98.0", "sass-embedded-unknown-all": "1.99.0",
"sass-embedded-win32-arm64": "1.98.0", "sass-embedded-win32-arm64": "1.99.0",
"sass-embedded-win32-x64": "1.98.0" "sass-embedded-win32-x64": "1.99.0"
} }
}, },
"node_modules/sass-embedded-all-unknown": { "node_modules/sass-embedded-all-unknown": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.99.0.tgz",
"integrity": "sha512-6n4RyK7/1mhdfYvpP3CClS3fGoYqDvRmLClCESS6I7+SAzqjxvGG6u5Fo+cb1nrPNbbilgbM4QKdgcgWHO9NCA==", "integrity": "sha512-qPIRG8Uhjo6/OKyAKixTnwMliTz+t9K6Duk0mx5z+K7n0Ts38NSJz2sjDnc7cA/8V9Lb3q09H38dZ1CLwD+ssw==",
"cpu": [ "cpu": [
"!arm", "!arm",
"!arm64", "!arm64",
@@ -2195,13 +2194,13 @@
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"sass": "1.98.0" "sass": "1.99.0"
} }
}, },
"node_modules/sass-embedded-android-arm": { "node_modules/sass-embedded-android-arm": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.99.0.tgz",
"integrity": "sha512-LjGiMhHgu7VL1n7EJxTCre1x14bUsWd9d3dnkS2rku003IWOI/fxc7OXgaKagoVzok1kv09rzO3vFXJR5ZeONQ==", "integrity": "sha512-EHvJ0C7/VuP78Qr6f8gIUVUmCqIorEQpw2yp3cs3SMg02ZuumlhjXvkTcFBxHmFdFR23vTNk1WnhY6QSeV1nFQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2216,9 +2215,9 @@
} }
}, },
"node_modules/sass-embedded-android-arm64": { "node_modules/sass-embedded-android-arm64": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.99.0.tgz",
"integrity": "sha512-M9Ra98A6vYJHpwhoC/5EuH1eOshQ9ZyNwC8XifUDSbRl/cGeQceT1NReR9wFj3L7s1pIbmes1vMmaY2np0uAKQ==", "integrity": "sha512-fNHhdnP23yqqieCbAdym4N47AleSwjbNt6OYIYx4DdACGdtERjQB4iOX/TaKsW034MupfF7SjnAAK8w7Ptldtg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2233,9 +2232,9 @@
} }
}, },
"node_modules/sass-embedded-android-riscv64": { "node_modules/sass-embedded-android-riscv64": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.99.0.tgz",
"integrity": "sha512-WPe+0NbaJIZE1fq/RfCZANMeIgmy83x4f+SvFOG7LhUthHpZWcOcrPTsCKKmN3xMT3iw+4DXvqTYOCYGRL3hcQ==", "integrity": "sha512-4zqDFRvgGDTL5vTHuIhRxUpXFoh0Cy7Gm5Ywk19ASd8Settmd14YdPRZPmMxfgS1GH292PofV1fq1ifiSEJWBw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -2250,9 +2249,9 @@
} }
}, },
"node_modules/sass-embedded-android-x64": { "node_modules/sass-embedded-android-x64": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.99.0.tgz",
"integrity": "sha512-zrD25dT7OHPEgLWuPEByybnIfx4rnCtfge4clBgjZdZ3lF6E7qNLRBtSBmoFflh6Vg0RlEjJo5VlpnTMBM5MQQ==", "integrity": "sha512-Uk53k/dGYt04RjOL4gFjZ0Z9DH9DKh8IA8WsXUkNqsxerAygoy3zqRBS2zngfE9K2jiOM87q+1R1p87ory9oQQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2267,9 +2266,9 @@
} }
}, },
"node_modules/sass-embedded-darwin-arm64": { "node_modules/sass-embedded-darwin-arm64": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.99.0.tgz",
"integrity": "sha512-cgr1z9rBnCdMf8K+JabIaYd9Rag2OJi5mjq08XJfbJGMZV/TA6hFJCLGkr5/+ZOn4/geTM5/3aSfQ8z5EIJAOg==", "integrity": "sha512-u61/7U3IGLqoO6gL+AHeiAtlTPFwJK1+964U8gp45ZN0hzh1yrARf5O1mivXv8NnNgJvbG2wWJbiNZP0lG/lTg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2284,9 +2283,9 @@
} }
}, },
"node_modules/sass-embedded-darwin-x64": { "node_modules/sass-embedded-darwin-x64": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.99.0.tgz",
"integrity": "sha512-OLBOCs/NPeiMqTdOrMFbVHBQFj19GS3bSVSxIhcCq16ZyhouUkYJEZjxQgzv9SWA2q6Ki8GCqp4k6jMeUY9dcA==", "integrity": "sha512-j/kkk/NcXdIameLezSfXjgCiBkVcA+G60AXrX768/3g0miK1g7M9dj7xOhCb1i7/wQeiEI3rw2LLuO63xRIn4A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2301,9 +2300,9 @@
} }
}, },
"node_modules/sass-embedded-linux-arm": { "node_modules/sass-embedded-linux-arm": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.99.0.tgz",
"integrity": "sha512-03baQZCxVyEp8v1NWBRlzGYrmVT/LK7ZrHlF1piscGiGxwfdxoLXVuxsylx3qn/dD/4i/rh7Bzk7reK1br9jvQ==", "integrity": "sha512-d4IjJZrX2+AwB2YCy1JySwdptJECNP/WfAQLUl8txI3ka8/d3TUI155GtelnoZUkio211PwIeFvvAeZ9RXPQnw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2319,9 +2318,9 @@
} }
}, },
"node_modules/sass-embedded-linux-arm64": { "node_modules/sass-embedded-linux-arm64": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.99.0.tgz",
"integrity": "sha512-axOE3t2MTBwCtkUCbrdM++Gj0gC0fdHJPrgzQ+q1WUmY9NoNMGqflBtk5mBZaWUeha2qYO3FawxCB8lctFwCtw==", "integrity": "sha512-btNcFpItcB56L40n8hDeL7sRSMLDXQ56nB5h2deddJx1n60rpKSElJmkaDGHtpkrY+CTtDRV0FZDjHeTJddYew==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2337,9 +2336,9 @@
} }
}, },
"node_modules/sass-embedded-linux-musl-arm": { "node_modules/sass-embedded-linux-musl-arm": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.99.0.tgz",
"integrity": "sha512-OBkjTDPYR4hSaueOGIM6FDpl9nt/VZwbSRpbNu9/eEJcxE8G/vynRugW8KRZmCFjPy8j/jkGBvvS+k9iOqKV3g==", "integrity": "sha512-2gvHOupgIw3ytatXT4nFUow71LFbuOZPEwG+HUzcNQDH8ue4Ez8cr03vsv5MDv3lIjOKcXwDvWD980t18MwkoQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2355,9 +2354,9 @@
} }
}, },
"node_modules/sass-embedded-linux-musl-arm64": { "node_modules/sass-embedded-linux-musl-arm64": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.99.0.tgz",
"integrity": "sha512-LeqNxQA8y4opjhe68CcFvMzCSrBuJqYVFbwElEj9bagHXQHTp9xVPJRn6VcrC+0VLEDq13HVXMv7RslIuU0zmA==", "integrity": "sha512-Hi2bt/IrM5P4FBKz6EcHAlniwfpoz9mnTdvSd58y+avA3SANM76upIkAdSayA8ZGwyL3gZokru1AKDPF9lJDNw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2373,9 +2372,9 @@
} }
}, },
"node_modules/sass-embedded-linux-musl-riscv64": { "node_modules/sass-embedded-linux-musl-riscv64": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.99.0.tgz",
"integrity": "sha512-7w6hSuOHKt8FZsmjRb3iGSxEzM87fO9+M8nt5JIQYMhHTj5C+JY/vcske0v715HCVj5e1xyTnbGXf8FcASeAIw==", "integrity": "sha512-mKqGvVaJ9rHMqyZsF0kikQe4NO0f4osb67+X6nLhBiVDKvyazQHJ3zJQreNefIE36yL2sjHIclSB//MprzaQDg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -2391,9 +2390,9 @@
} }
}, },
"node_modules/sass-embedded-linux-musl-x64": { "node_modules/sass-embedded-linux-musl-x64": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.99.0.tgz",
"integrity": "sha512-QikNyDEJOVqPmxyCFkci8ZdCwEssdItfjQFJB+D+Uy5HFqcS5Lv3d3GxWNX/h1dSb23RPyQdQc267ok5SbEyJw==", "integrity": "sha512-huhgOMmOc30r7CH7qbRbT9LerSEGSnWuS4CYNOskr9BvNeQp4dIneFufNRGZ7hkOAxUM8DglxIZJN/cyAT95Ew==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2409,9 +2408,9 @@
} }
}, },
"node_modules/sass-embedded-linux-riscv64": { "node_modules/sass-embedded-linux-riscv64": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.99.0.tgz",
"integrity": "sha512-E7fNytc/v4xFBQKzgzBddV/jretA4ULAPO6XmtBiQu4zZBdBozuSxsQLe2+XXeb0X4S2GIl72V7IPABdqke/vA==", "integrity": "sha512-mevFPIFAVhrH90THifxLfOntFmHtcEKOcdWnep2gJ0X4DVva4AiVIRlQe/7w9JFx5+gnDRE1oaJJkzuFUuYZsA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -2427,9 +2426,9 @@
} }
}, },
"node_modules/sass-embedded-linux-x64": { "node_modules/sass-embedded-linux-x64": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.99.0.tgz",
"integrity": "sha512-VsvP0t/uw00mMNPv3vwyYKUrFbqzxQHnRMO+bHdAMjvLw4NFf6mscpym9Bzf+NXwi1ZNKnB6DtXjmcpcvqFqYg==", "integrity": "sha512-9k7IkULqIZdCIVt4Mboryt6vN8Mjmm3EhI1P3mClU5y5i3wLK5ExC3cbVWk047KsID/fvB1RLslqghXJx5BoxA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2445,9 +2444,9 @@
} }
}, },
"node_modules/sass-embedded-unknown-all": { "node_modules/sass-embedded-unknown-all": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.99.0.tgz",
"integrity": "sha512-C4MMzcAo3oEDQnW7L8SBgB9F2Fq5qHPnaYTZRMOH3Mp/7kM4OooBInXpCiiFjLnjY95hzP4KyctVx0uYR6MYlQ==", "integrity": "sha512-P7MxiUtL/XzGo3PX0CaB8lNNEFLQWKikPA8pbKytx9ZCLZSDkt2NJcdAbblB/sqMs4AV3EK2NadV8rI/diq3xg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -2458,13 +2457,13 @@
"!win32" "!win32"
], ],
"dependencies": { "dependencies": {
"sass": "1.98.0" "sass": "1.99.0"
} }
}, },
"node_modules/sass-embedded-win32-arm64": { "node_modules/sass-embedded-win32-arm64": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.99.0.tgz",
"integrity": "sha512-nP/10xbAiPbhQkMr3zQfXE4TuOxPzWRQe1Hgbi90jv2R4TbzbqQTuZVOaJf7KOAN4L2Bo6XCTRjK5XkVnwZuwQ==", "integrity": "sha512-8whpsW7S+uO8QApKfQuc36m3P9EISzbVZOgC79goob4qGy09u8Gz/rYvw8h1prJDSjltpHGhOzBE6LDz7WvzVw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2479,9 +2478,9 @@
} }
}, },
"node_modules/sass-embedded-win32-x64": { "node_modules/sass-embedded-win32-x64": {
"version": "1.98.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.99.0.tgz",
"integrity": "sha512-/lbrVsfbcbdZQ5SJCWcV0NVPd6YRs+FtAnfedp4WbCkO/ZO7Zt/58MvI4X2BVpRY/Nt5ZBo1/7v2gYcQ+J4svQ==", "integrity": "sha512-ipuOv1R2K4MHeuCEAZGpuUbAgma4gb0sdacyrTjJtMOy/OY9UvWfVlwErdB09KIkp4fPDpQJDJfvYN6bC8jeNg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2675,16 +2674,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.3", "version": "8.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"rolldown": "1.0.0-rc.12", "rolldown": "1.0.0-rc.15",
"tinyglobby": "^0.2.15" "tinyglobby": "^0.2.15"
}, },
"bin": { "bin": {
@@ -2702,7 +2701,7 @@
"peerDependencies": { "peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0", "@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0", "@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0", "esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0", "jiti": ">=1.21.0",
"less": "^4.0.0", "less": "^4.0.0",
"sass": "^1.70.0", "sass": "^1.70.0",
@@ -2753,16 +2752,16 @@
} }
}, },
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.31", "version": "3.5.32",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
"integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==", "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.31", "@vue/compiler-dom": "3.5.32",
"@vue/compiler-sfc": "3.5.31", "@vue/compiler-sfc": "3.5.32",
"@vue/runtime-dom": "3.5.31", "@vue/runtime-dom": "3.5.32",
"@vue/server-renderer": "3.5.31", "@vue/server-renderer": "3.5.32",
"@vue/shared": "3.5.31" "@vue/shared": "3.5.32"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "*" "typescript": "*"
@@ -2774,14 +2773,14 @@
} }
}, },
"node_modules/vue-i18n": { "node_modules/vue-i18n": {
"version": "11.3.0", "version": "11.3.2",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz", "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.2.tgz",
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==", "integrity": "sha512-gmFrvM+iuf2AH4ygligw/pC7PRJ63AdRNE68E0GPlQ83Mzfyck6g6cRQC3KzkYXr+ZidR91wq+5YBmAMpkgE1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/core-base": "11.3.0", "@intlify/core-base": "11.3.2",
"@intlify/devtools-types": "11.3.0", "@intlify/devtools-types": "11.3.2",
"@intlify/shared": "11.3.0", "@intlify/shared": "11.3.2",
"@vue/devtools-api": "^6.5.0" "@vue/devtools-api": "^6.5.0"
}, },
"engines": { "engines": {

View File

@@ -23,14 +23,14 @@
"is-ip": "^5.0.1", "is-ip": "^5.0.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"vue": "^3.5.31", "vue": "^3.5.32",
"vue-i18n": "^11.3.0", "vue-i18n": "^11.3.2",
"vue-prism-component": "github:h44z/vue-prism-component", "vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^5.0.4" "vue-router": "^5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.6",
"sass-embedded": "^1.98.0", "sass-embedded": "^1.99.0",
"vite": "^8.0.3" "vite": "^8.0.8"
} }
} }

View File

@@ -53,6 +53,7 @@ const formData = ref(freshInterface())
const isSaving = ref(false) const isSaving = ref(false)
const isDeleting = ref(false) const isDeleting = ref(false)
const isApplyingDefaults = ref(false) const isApplyingDefaults = ref(false)
const isCreatingDefaultPeers = ref(false)
const isBackendValid = computed(() => { const isBackendValid = computed(() => {
if (!props.visible || !selectedInterface.value) { if (!props.visible || !selectedInterface.value) {
@@ -313,6 +314,39 @@ async function applyPeerDefaults() {
} }
} }
async function createDefaultPeers() {
if (props.interfaceId==='#NEW#') {
return; // do nothing for new interfaces
}
if (!formData.value.CreateDefaultPeer) {
return; // only allowed if the interface flag is set
}
if (isCreatingDefaultPeers.value) return
isCreatingDefaultPeers.value = true
try {
await interfaces.CreateDefaultPeers(selectedInterface.value.Identifier)
notify({
title: "Default Peers Created",
text: "Created default peers for all users on this interface.",
type: 'success',
})
await peers.LoadPeers(selectedInterface.value.Identifier) // reload peers list
} catch (e) {
console.log(e)
notify({
title: "Failed to create default peers!",
text: e.toString(),
type: 'error',
})
} finally {
isCreatingDefaultPeers.value = false
}
}
async function del() { async function del() {
if (isDeleting.value) return if (isDeleting.value) return
if (!confirm(t('modals.interface-edit.confirm-delete', {id: selectedInterface.value.Identifier}))) return if (!confirm(t('modals.interface-edit.confirm-delete', {id: selectedInterface.value.Identifier}))) return
@@ -490,10 +524,16 @@ async function del() {
<input v-model="formData.Disabled" class="form-check-input" type="checkbox"> <input v-model="formData.Disabled" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label> <label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label>
</div> </div>
<div class="form-check form-switch" v-if="formData.Mode==='server' && settings.Setting('CreateDefaultPeer')"> <div class="d-flex align-items-center justify-content-between" v-if="formData.Mode==='server' && settings.Setting('CreateDefaultPeer')">
<div class="form-check form-switch mb-0">
<input v-model="formData.CreateDefaultPeer" class="form-check-input" type="checkbox"> <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> <label class="form-check-label">{{ $t('modals.interface-edit.create-default-peer.label') }}</label>
</div> </div>
<button v-if="props.interfaceId!=='#NEW#'" class="btn btn-primary btn-sm" type="button" @click.prevent="createDefaultPeers" :disabled="!formData.CreateDefaultPeer || isCreatingDefaultPeers">
<span v-if="isCreatingDefaultPeers" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
{{ $t('modals.interface-edit.button-create-default-peers') }}
</button>
</div>
<div class="form-check form-switch" v-if="formData.Backend==='local'"> <div class="form-check form-switch" v-if="formData.Backend==='local'">
<input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox"> <input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label> <label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label>

View File

@@ -0,0 +1,121 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
currentPage: {
type: Number,
required: true
},
totalCount: {
type: Number,
required: true
},
pageSize: {
type: Number,
required: true
},
onGotoPage: {
type: Function,
required: true
},
onNextPage: {
type: Function,
required: true
},
onPrevPage: {
type: Function,
required: true
},
hasNextPage: {
type: Boolean,
required: true
},
hasPrevPage: {
type: Boolean,
required: true
}
});
const totalPages = computed(() => Math.ceil(props.totalCount / props.pageSize));
const pages = computed(() => {
const current = props.currentPage;
const last = totalPages.value;
const delta = 2; // Number of pages to show before and after current page
const range = [];
const rangeWithDots = [];
// If total pages is small, just show all pages
if (last <= 7) {
for (let i = 1; i <= last; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
return rangeWithDots;
}
// Calculate the range around the current page
let start = Math.max(2, current - delta);
let end = Math.min(last - 1, current + delta);
// Adjust range to always show a consistent number of pages if possible
if (current <= delta + 2) {
end = 2 + delta * 2;
} else if (current >= last - delta - 1) {
start = last - delta * 2 - 1;
}
// Add dots before the range if needed
if (start > 2) {
rangeWithDots.push({ type: 'page', value: 1 });
rangeWithDots.push({ type: 'dots', value: 'dots-start' });
} else {
for (let i = 1; i < start; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
}
// Add the central range
for (let i = start; i <= end; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
// Add dots after the range if needed
if (end < last - 1) {
rangeWithDots.push({ type: 'dots', value: 'dots-end' });
rangeWithDots.push({ type: 'page', value: last });
} else {
for (let i = end + 1; i <= last; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
}
return rangeWithDots;
});
</script>
<template>
<ul class="pagination pagination-sm mb-0" v-if="totalPages > 1">
<li :class="{ disabled: !hasPrevPage }" class="page-item">
<a class="page-link" href="#" @click.prevent="hasPrevPage && onPrevPage()">&laquo;</a>
</li>
<li v-for="item in pages" :key="item.type === 'page' ? item.value : item.value" :class="{ active: currentPage === item.value, disabled: item.type === 'dots' }" class="page-item">
<a v-if="item.type === 'page'" class="page-link" href="#" @click.prevent="onGotoPage(item.value)">{{ item.value }}</a>
<span v-else class="page-link">...</span>
</li>
<li :class="{ disabled: !hasNextPage }" class="page-item">
<a class="page-link" href="#" @click.prevent="hasNextPage && onNextPage()">&raquo;</a>
</li>
</ul>
</template>
<style scoped>
.page-link {
cursor: pointer;
}
.page-item.disabled .page-link {
cursor: default;
}
</style>

View File

@@ -9,6 +9,7 @@ import uk from './translations/uk.json';
import vi from './translations/vi.json'; import vi from './translations/vi.json';
import zh from './translations/zh.json'; import zh from './translations/zh.json';
import es from './translations/es.json'; import es from './translations/es.json';
import ja from './translations/ja.json';
import {createI18n} from "vue-i18n"; import {createI18n} from "vue-i18n";
@@ -34,6 +35,7 @@ const i18n = createI18n({
"vi": vi, "vi": vi,
"zh": zh, "zh": zh,
"es": es, "es": es,
"ja": ja,
} }
}); });

View File

@@ -505,6 +505,7 @@
} }
}, },
"button-apply-defaults": "Peer-Standardeinstellungen anwenden", "button-apply-defaults": "Peer-Standardeinstellungen anwenden",
"button-create-default-peers": "Standard-Peers erstellen",
"confirm-delete": "Interface '{id}' wirklich löschen?" "confirm-delete": "Interface '{id}' wirklich löschen?"
}, },
"peer-view": { "peer-view": {

View File

@@ -505,6 +505,7 @@
} }
}, },
"button-apply-defaults": "Apply Peer Defaults", "button-apply-defaults": "Apply Peer Defaults",
"button-create-default-peers": "Create Default Peers",
"confirm-delete": "Are you sure you want to delete interface '{id}'?" "confirm-delete": "Are you sure you want to delete interface '{id}'?"
}, },
"peer-view": { "peer-view": {

View File

@@ -495,6 +495,7 @@
} }
}, },
"button-apply-defaults": "Aplicar Valores Predeterminados de peers", "button-apply-defaults": "Aplicar Valores Predeterminados de peers",
"button-create-default-peers": "Crear Peers Predeterminados",
"confirm-delete": "Seguro que desea eliminar la interfaz '{id}'?" "confirm-delete": "Seguro que desea eliminar la interfaz '{id}'?"
}, },
"peer-view": { "peer-view": {

View File

@@ -377,6 +377,7 @@
} }
}, },
"button-apply-defaults": "Appliquer les valeurs par défaut des pairs", "button-apply-defaults": "Appliquer les valeurs par défaut des pairs",
"button-create-default-peers": "Créer les pairs par défaut",
"confirm-delete": "Voulez-vous vraiment supprimer l'interface \"{id}\" ?" "confirm-delete": "Voulez-vous vraiment supprimer l'interface \"{id}\" ?"
}, },
"peer-view": { "peer-view": {

View File

@@ -0,0 +1,651 @@
{
"languages": {
"en": "日本語"
},
"general": {
"pagination": {
"size": "件数",
"all": "全件 (低速)"
},
"search": {
"placeholder": "検索...",
"button": "検索"
},
"select-all": "すべて選択",
"yes": "はい",
"no": "いいえ",
"cancel": "キャンセル",
"close": "閉じる",
"save": "保存",
"delete": "削除"
},
"login": {
"headline": "ログイン",
"username": {
"label": "ユーザー名",
"placeholder": "ユーザー名を入力してください"
},
"password": {
"label": "パスワード",
"placeholder": "パスワードを入力してください"
},
"button": "ログイン",
"button-webauthn": "パスキーでログイン"
},
"menu": {
"home": "ホーム",
"interfaces": "インターフェース",
"users": "ユーザー",
"lang": "言語切替",
"profile": "マイプロフィール",
"settings": "設定",
"audit": "監査ログ",
"login": "ログイン",
"logout": "ログアウト",
"keygen": "鍵生成",
"calculator": "IP計算機"
},
"home": {
"headline": "WireGuard® VPN ポータル",
"info-headline": "詳細情報",
"abstract": "WireGuard® は最新の暗号技術を活用した、シンプルかつ高速なモダンVPNです。IPsec より高速・シンプル・軽量・実用的な設計を目指し、OpenVPN を大きく上回る性能を発揮します。",
"installation": {
"box-header": "WireGuard インストール",
"headline": "インストール",
"content": "クライアントソフトウェアのインストール手順は WireGuard 公式サイトでご確認いただけます。",
"button": "インストール手順を開く"
},
"about-wg": {
"box-header": "WireGuard について",
"headline": "概要",
"content": "WireGuard® は最新の暗号技術を活用したシンプルかつ高速なモダンVPNです。",
"button": "詳細"
},
"about-portal": {
"box-header": "WireGuard Portal について",
"headline": "WireGuard Portal",
"content": "WireGuard Portal は WireGuard を Web から簡単に設定できる管理ポータルです。",
"button": "詳細"
},
"profiles": {
"headline": "VPN プロファイル",
"abstract": "個人 VPN 設定の確認・ダウンロードはユーザープロファイルから行えます。",
"content": "設定済みプロファイルの一覧は下のボタンから開けます。",
"button": "マイプロフィールを開く"
},
"admin": {
"headline": "管理エリア",
"abstract": "管理エリアでは、WireGuard ピア、サーバーインターフェース、および WireGuard Portal にログイン可能なユーザーを管理できます。",
"content": "",
"button-admin": "サーバー管理を開く",
"button-user": "ユーザー管理を開く"
}
},
"interfaces": {
"headline": "インターフェース管理",
"headline-peers": "現在の VPN ピア",
"headline-endpoints": "現在のエンドポイント",
"no-interface": {
"default-selection": "利用可能なインターフェースなし",
"headline": "インターフェースが見つかりません...",
"abstract": "上の「+」ボタンから新しい WireGuard インターフェースを作成してください。"
},
"no-peer": {
"headline": "ピアがありません",
"abstract": "選択した WireGuard インターフェースには現在ピアが登録されていません。"
},
"table-heading": {
"name": "名前",
"user": "ユーザー",
"ip": "IP",
"endpoint": "エンドポイント",
"status": "ステータス"
},
"interface": {
"headline": "インターフェース状態:",
"backend": "バックエンド",
"unknown-backend": "不明",
"wrong-backend": "バックエンドが無効です。代わりにローカル WireGuard バックエンドを使用します。",
"key": "公開鍵",
"endpoint": "公開エンドポイント",
"port": "待受ポート",
"peers": "有効ピア数",
"total-peers": "全ピア数",
"endpoints": "有効エンドポイント数",
"total-endpoints": "全エンドポイント数",
"ip": "IPアドレス",
"default-allowed-ip": "デフォルト許可IP",
"dns": "DNSサーバー",
"mtu": "MTU",
"default-keep-alive": "デフォルトキープアライブ間隔",
"default-dns": "デフォルトDNSサーバー",
"button-show-config": "設定を表示",
"button-download-config": "設定をダウンロード",
"button-store-config": "wg-quick 用に設定を保存",
"button-edit": "インターフェースを編集"
},
"button-add-interface": "インターフェース追加",
"button-add-peer": "ピア追加",
"button-add-peers": "複数ピアを追加",
"button-show-peer": "ピア詳細",
"button-edit-peer": "ピア編集",
"button-bulk-delete": "選択したピアを削除",
"button-bulk-enable": "選択したピアを有効化",
"button-bulk-disable": "選択したピアを無効化",
"confirm-bulk-delete": "{count} 件のピアを削除してもよろしいですか?",
"confirm-bulk-disable": "{count} 件のピアを無効化してもよろしいですか?",
"peer-disabled": "ピアは無効化されています。理由:",
"peer-expiring": "ピアの有効期限:",
"peer-connected": "接続中",
"peer-not-connected": "未接続",
"peer-handshake": "最終ハンドシェイク:"
},
"users": {
"headline": "ユーザー管理",
"table-heading": {
"id": "ID",
"email": "メール",
"firstname": "名",
"lastname": "姓",
"sources": "ソース",
"peers": "ピア",
"admin": "管理者"
},
"no-user": {
"headline": "ユーザーがいません",
"abstract": "現在 WireGuard Portal に登録されているユーザーはいません。"
},
"button-add-user": "ユーザー追加",
"button-show-user": "ユーザー詳細",
"button-edit-user": "ユーザー編集",
"button-bulk-delete": "選択したユーザーを削除",
"button-bulk-enable": "選択したユーザーを有効化",
"button-bulk-disable": "選択したユーザーを無効化",
"button-bulk-lock": "選択したユーザーをロック",
"button-bulk-unlock": "選択したユーザーのロック解除",
"confirm-bulk-delete": "{count} 件のユーザーを削除してもよろしいですか?",
"confirm-bulk-disable": "{count} 件のユーザーを無効化してもよろしいですか?",
"confirm-bulk-lock": "{count} 件のユーザーをロックしてもよろしいですか?",
"user-disabled": "ユーザーは無効化されています。理由:",
"user-locked": "アカウントはロックされています。理由:",
"admin": "管理者権限あり",
"no-admin": "管理者権限なし"
},
"profile": {
"headline": "マイ VPN ピア",
"table-heading": {
"name": "名前",
"ip": "IP",
"stats": "ステータス",
"interface": "サーバーインターフェース"
},
"no-peer": {
"headline": "ピアがありません",
"abstract": "現在、あなたのユーザープロフィールにはピアが関連付けられていません。"
},
"peer-connected": "接続中",
"button-add-peer": "ピア追加",
"button-show-peer": "ピア詳細",
"button-edit-peer": "ピア編集"
},
"settings": {
"headline": "設定",
"abstract": "ここで個人設定を変更できます。",
"api": {
"headline": "API 設定",
"abstract": "RESTful API の設定はこちらで行います。",
"active-description": "あなたのアカウントで API は現在有効です。すべての API リクエストは Basic 認証で行います。以下の認証情報を使用してください。",
"inactive-description": "API は現在無効です。下のボタンを押して有効化してください。",
"user-label": "API ユーザー名:",
"user-placeholder": "API ユーザー",
"token-label": "API パスワード:",
"token-placeholder": "API トークン",
"token-created-label": "API アクセス許可日時: ",
"button-disable-title": "API を無効化します。現在のトークンは無効になります。",
"button-disable-text": "API を無効化",
"button-enable-title": "API を有効化します。新しいトークンが生成されます。",
"button-enable-text": "API を有効化",
"api-link": "API ドキュメント"
},
"webauthn": {
"headline": "パスキー設定",
"abstract": "パスキーはパスワード不要でユーザー認証を行う最新の方法です。ブラウザに安全に保存され、WireGuard Portal へのログインに使用できます。",
"active-description": "現在、あなたのアカウントには少なくとも 1 つのパスキーが登録されています。",
"inactive-description": "あなたのアカウントにはパスキーが登録されていません。下のボタンから新しいパスキーを登録してください。",
"table": {
"name": "名前",
"created": "作成日時",
"actions": ""
},
"credentials-list": "登録済みパスキー",
"modal-delete": {
"headline": "パスキー削除",
"abstract": "このパスキーを削除してもよろしいですか? 削除後はこのパスキーでログインできなくなります。",
"created": "作成日時:",
"button-delete": "削除",
"button-cancel": "キャンセル"
},
"button-rename-title": "リネーム",
"button-rename-text": "パスキーの名前を変更します。",
"button-save-title": "保存",
"button-save-text": "新しいパスキー名を保存します。",
"button-cancel-title": "キャンセル",
"button-cancel-text": "リネームをキャンセルします。",
"button-delete-title": "削除",
"button-delete-text": "パスキーを削除します。削除後はこのパスキーでログインできなくなります。",
"button-register-title": "パスキー登録",
"button-register-text": "新しいパスキーを登録してアカウントを保護します。"
},
"password": {
"headline": "パスワード設定",
"abstract": "ここでパスワードを変更できます。",
"current-label": "現在のパスワード",
"new-label": "新しいパスワード",
"new-confirm-label": "新しいパスワード(確認)",
"change-button-text": "パスワード変更",
"invalid-confirm-label": "パスワードが一致しません",
"weak-label": "パスワードが脆弱です"
}
},
"audit": {
"headline": "監査ログ",
"abstract": "WireGuard Portal で実行されたすべての操作の監査ログを確認できます。",
"no-entries": {
"headline": "ログがありません",
"abstract": "現在、監査ログは記録されていません。"
},
"entries-headline": "ログエントリ",
"table-heading": {
"id": "#",
"time": "日時",
"user": "ユーザー",
"severity": "重要度",
"origin": "発生元",
"message": "メッセージ"
}
},
"keygen": {
"headline": "WireGuard 鍵生成",
"abstract": "新しい WireGuard 鍵を生成します。鍵はローカルブラウザで生成され、サーバーには送信されません。",
"headline-keypair": "新しい鍵ペア",
"headline-preshared-key": "新しい事前共有鍵",
"button-generate": "生成",
"private-key": {
"label": "秘密鍵",
"placeholder": "秘密鍵"
},
"public-key": {
"label": "公開鍵",
"placeholder": "公開鍵"
},
"preshared-key": {
"label": "事前共有鍵",
"placeholder": "事前共有鍵"
}
},
"calculator": {
"headline": "WireGuard IP 計算機",
"abstract": "WireGuard の Allowed IPs を生成します。IP サブネットはローカルブラウザで生成され、サーバーには送信されません。",
"headline-allowed-ip": "新しい Allowed IPs",
"button-exclude-private": "プライベートIP範囲を除外",
"allowed-ip": {
"label": "Allowed IPs",
"placeholder": "0.0.0.0/0, ::/0",
"empty": "値は必須です"
},
"dissallowed-ip": {
"label": "除外IP",
"placeholder": "10.0.0.0/8, 192.168.0.0/16",
"invalid": "無効なアドレス: {addr}"
},
"new-allowed-ip": {
"label": "Allowed IPs",
"placeholder": ""
}
},
"modals": {
"user-view": {
"headline": "ユーザーアカウント:",
"tab-user": "情報",
"tab-peers": "ピア",
"headline-info": "ユーザー情報:",
"headline-notes": "備考:",
"email": "メール",
"firstname": "名",
"lastname": "姓",
"phone": "電話番号",
"department": "部署",
"api-enabled": "API アクセス",
"disabled": "アカウント無効",
"locked": "アカウントロック中",
"no-peers": "このユーザーには関連するピアがありません。",
"peers": {
"name": "名前",
"interface": "インターフェース",
"ip": "IP"
}
},
"user-edit": {
"headline-edit": "ユーザー編集:",
"headline-new": "新規ユーザー",
"header-general": "全般",
"header-personal": "ユーザー情報",
"header-notes": "備考",
"header-state": "状態",
"identifier": {
"label": "識別子",
"placeholder": "一意のユーザー識別子"
},
"source": {
"label": "ソース",
"placeholder": "ユーザーのソース"
},
"password": {
"label": "パスワード",
"placeholder": "強固なパスワード",
"description": "現在のパスワードを保持する場合は空のままにします。",
"too-weak": "パスワードが脆弱です。より強固なパスワードを使用してください。"
},
"email": {
"label": "メール",
"placeholder": "メールアドレス"
},
"phone": {
"label": "電話",
"placeholder": "電話番号"
},
"department": {
"label": "部署",
"placeholder": "部署名"
},
"firstname": {
"label": "名",
"placeholder": "名"
},
"lastname": {
"label": "姓",
"placeholder": "姓"
},
"notes": {
"label": "備考",
"placeholder": ""
},
"disabled": {
"label": "無効化 (WireGuard 接続およびログインを禁止)"
},
"locked": {
"label": "ロック (ログイン禁止、WireGuard 接続は引き続き有効)"
},
"admin": {
"label": "管理者"
},
"persist-local-changes": {
"label": "ローカル変更を保持"
},
"sync-warning": "同期されたユーザーを変更するには、ローカル変更の保持を有効化してください。そうしないと次回の同期時に変更が上書きされます。",
"confirm-delete": "ユーザー '{id}' を削除してもよろしいですか?"
},
"interface-view": {
"headline": "インターフェース設定:"
},
"interface-edit": {
"headline-edit": "インターフェース編集:",
"headline-new": "新規インターフェース",
"tab-interface": "インターフェース",
"tab-peerdef": "ピアのデフォルト",
"header-general": "全般",
"header-network": "ネットワーク",
"header-crypto": "暗号化",
"header-hooks": "インターフェースフック",
"header-peer-hooks": "フック",
"header-state": "状態",
"identifier": {
"label": "識別子",
"placeholder": "一意のインターフェース識別子"
},
"mode": {
"label": "インターフェースモード",
"server": "サーバーモード",
"client": "クライアントモード",
"any": "不明モード"
},
"backend": {
"label": "インターフェースバックエンド",
"invalid-label": "元のバックエンドが利用できなくなったため、ローカル WireGuard バックエンドを使用します。",
"local": "ローカル WireGuard バックエンド"
},
"display-name": {
"label": "表示名",
"placeholder": "インターフェースの説明的な名前"
},
"private-key": {
"label": "秘密鍵",
"placeholder": "秘密鍵"
},
"public-key": {
"label": "公開鍵",
"placeholder": "公開鍵"
},
"ip": {
"label": "IPアドレス",
"placeholder": "IPアドレス (CIDR 形式)"
},
"listen-port": {
"label": "待受ポート",
"placeholder": "待ち受けポート"
},
"dns": {
"label": "DNSサーバー",
"placeholder": "使用するDNSサーバー"
},
"dns-search": {
"label": "DNS 検索ドメイン",
"placeholder": "DNS 検索プレフィックス"
},
"mtu": {
"label": "MTU",
"placeholder": "インターフェース MTU (0 = デフォルト)"
},
"firewall-mark": {
"label": "ファイアウォールマーク",
"placeholder": "送信トラフィックに付与するファイアウォールマーク (0 = 自動)"
},
"routing-table": {
"label": "ルーティングテーブル",
"placeholder": "ルーティングテーブルID",
"description": "特殊値: off = 経路を管理しない、0 = 自動"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"post-up": {
"label": "Post-Up",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"post-down": {
"label": "Post-Down",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"disabled": {
"label": "インターフェース無効化"
},
"create-default-peer": {
"label": "新規ユーザー用にデフォルトピアを作成"
},
"save-config": {
"label": "wg-quick 設定を自動保存"
},
"defaults": {
"endpoint": {
"label": "エンドポイントアドレス",
"placeholder": "エンドポイントアドレス",
"description": "ピアが接続するエンドポイントアドレス。(例: wg.example.com または wg.example.com:51820)"
},
"networks": {
"label": "IP ネットワーク",
"placeholder": "ネットワークアドレス",
"description": "ピアにはこれらのサブネットから IP が割り当てられます。"
},
"allowed-ip": {
"label": "Allowed IPs",
"placeholder": "デフォルトの Allowed IPs"
},
"mtu": {
"label": "MTU",
"placeholder": "クライアント MTU (0 = デフォルト)"
},
"keep-alive": {
"label": "キープアライブ間隔",
"placeholder": "Persistent Keepalive (0 = デフォルト)"
}
},
"button-apply-defaults": "ピアのデフォルトを適用",
"button-create-default-peers": "デフォルトピアを作成",
"confirm-delete": "インターフェース '{id}' を削除してもよろしいですか?"
},
"peer-view": {
"headline-peer": "ピア:",
"headline-endpoint": "エンドポイント:",
"section-info": "ピア情報",
"section-status": "現在の状態",
"section-config": "設定",
"identifier": "識別子",
"ip": "IPアドレス",
"allowed-ip": "Allowed IPs",
"extra-allowed-ip": "サーバー側 Allowed IPs",
"user": "関連ユーザー",
"notes": "備考",
"expiry-status": "有効期限",
"disabled-status": "無効化日時",
"traffic": "通信量",
"connection-status": "接続統計",
"upload": "アップロード(サーバー → ピア、バイト)",
"download": "ダウンロード(ピア → サーバー、バイト)",
"pingable": "ping 応答",
"handshake": "最終ハンドシェイク",
"connected-since": "接続開始日時",
"endpoint": "エンドポイント",
"endpoint-key": "エンドポイント公開鍵",
"keepalive": "Persistent Keepalive",
"button-download": "設定をダウンロード",
"button-email": "設定をメール送信",
"style-label": "設定スタイル"
},
"peer-edit": {
"headline-edit-peer": "ピア編集:",
"headline-edit-endpoint": "エンドポイント編集:",
"headline-new-peer": "ピア作成",
"headline-new-endpoint": "エンドポイント作成",
"header-general": "全般",
"header-network": "ネットワーク",
"header-crypto": "暗号化",
"header-hooks": "フック (ピア側で実行)",
"header-state": "状態",
"display-name": {
"label": "表示名",
"placeholder": "ピアの説明的な名前"
},
"linked-user": {
"label": "関連ユーザー",
"placeholder": "このピアを所有するユーザーアカウント"
},
"private-key": {
"label": "秘密鍵",
"placeholder": "秘密鍵",
"help": "秘密鍵はサーバー上に安全に保存されます。すでにユーザーがコピーを持っている場合は空欄でも構いません。サーバーはピアの公開鍵のみで動作します。"
},
"public-key": {
"label": "公開鍵",
"placeholder": "公開鍵"
},
"preshared-key": {
"label": "事前共有鍵",
"placeholder": "オプションの事前共有鍵"
},
"endpoint-public-key": {
"label": "エンドポイント公開鍵",
"placeholder": "リモートエンドポイントの公開鍵"
},
"endpoint": {
"label": "エンドポイントアドレス",
"placeholder": "リモートエンドポイントのアドレス"
},
"ip": {
"label": "IPアドレス",
"placeholder": "IPアドレス (CIDR 形式)"
},
"allowed-ip": {
"label": "Allowed IPs",
"placeholder": "Allowed IPs (CIDR 形式)"
},
"extra-allowed-ip": {
"label": "追加の Allowed IPs",
"placeholder": "追加 Allowed IPs (サーバー側)",
"description": "これらの IP はリモート WireGuard インターフェースの Allowed IPs に追加されます。"
},
"dns": {
"label": "DNSサーバー",
"placeholder": "使用するDNSサーバー"
},
"dns-search": {
"label": "DNS 検索ドメイン",
"placeholder": "DNS 検索プレフィックス"
},
"keep-alive": {
"label": "キープアライブ間隔",
"placeholder": "Persistent Keepalive (0 = デフォルト)"
},
"mtu": {
"label": "MTU",
"placeholder": "クライアント MTU (0 = デフォルト)"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"post-up": {
"label": "Post-Up",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"post-down": {
"label": "Post-Down",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"disabled": {
"label": "ピア無効化"
},
"ignore-global": {
"label": "グローバル設定を無視"
},
"expires-at": {
"label": "有効期限"
},
"confirm-delete": "ピア '{id}' を削除してもよろしいですか?"
},
"peer-multi-create": {
"headline-peer": "複数ピア作成",
"headline-endpoint": "複数エンドポイント作成",
"identifiers": {
"label": "ユーザー識別子",
"placeholder": "ユーザー識別子",
"description": "ピアを作成するユーザー識別子(ユーザー名)。"
},
"prefix": {
"headline-peer": "ピア:",
"headline-endpoint": "エンドポイント:",
"label": "表示名プレフィックス",
"placeholder": "プレフィックス",
"description": "ピア表示名に追加されるプレフィックス。"
}
}
}
}

View File

@@ -395,6 +395,7 @@
} }
}, },
"button-apply-defaults": "피어 기본값 적용", "button-apply-defaults": "피어 기본값 적용",
"button-create-default-peers": "기본 피어 생성",
"confirm-delete": "인터페이스 '{id}'를 삭제하시겠습니까?" "confirm-delete": "인터페이스 '{id}'를 삭제하시겠습니까?"
}, },
"peer-view": { "peer-view": {

View File

@@ -415,6 +415,7 @@
} }
}, },
"button-apply-defaults": "Aplicar Padrões de Peer", "button-apply-defaults": "Aplicar Padrões de Peer",
"button-create-default-peers": "Criar Peers Padrão",
"confirm-delete": "Tem certeza que deseja excluir a interface '{id}'?" "confirm-delete": "Tem certeza que deseja excluir a interface '{id}'?"
}, },
"peer-view": { "peer-view": {

View File

@@ -486,6 +486,7 @@
} }
}, },
"button-apply-defaults": "Применить настройки пира по умолчанию", "button-apply-defaults": "Применить настройки пира по умолчанию",
"button-create-default-peers": "Создать пиров по умолчанию",
"confirm-delete": "Вы уверены, что хотите удалить интерфейс «{id}»?" "confirm-delete": "Вы уверены, что хотите удалить интерфейс «{id}»?"
}, },
"peer-view": { "peer-view": {

View File

@@ -377,6 +377,7 @@
} }
}, },
"button-apply-defaults": "Застосувати значення за замовчуванням для пірів", "button-apply-defaults": "Застосувати значення за замовчуванням для пірів",
"button-create-default-peers": "Створити пірів за замовчуванням",
"confirm-delete": "Ви впевнені, що хочете видалити інтерфейс «{id}»?" "confirm-delete": "Ви впевнені, що хочете видалити інтерфейс «{id}»?"
}, },
"peer-view": { "peer-view": {

View File

@@ -355,6 +355,7 @@
} }
}, },
"button-apply-defaults": "Áp dụng Cài đặt Mặc định của Peer", "button-apply-defaults": "Áp dụng Cài đặt Mặc định của Peer",
"button-create-default-peers": "Tạo Peer Mặc định",
"confirm-delete": "Ban co chac muon xoa giao dien '{id}' khong?" "confirm-delete": "Ban co chac muon xoa giao dien '{id}' khong?"
}, },
"peer-view": { "peer-view": {

View File

@@ -355,6 +355,7 @@
} }
}, },
"button-apply-defaults": "应用节点默认值", "button-apply-defaults": "应用节点默认值",
"button-create-default-peers": "创建默认节点",
"confirm-delete": "确定要删除接口“{id}”吗?" "confirm-delete": "确定要删除接口“{id}”吗?"
}, },
"peer-view": { "peer-view": {

View File

@@ -6,8 +6,10 @@ import {authStore} from '@/stores/auth'
import {securityStore} from '@/stores/security' import {securityStore} from '@/stores/security'
import {notify} from "@kyvg/vue3-notification"; import {notify} from "@kyvg/vue3-notification";
const routerBase = `${WGPORTAL_BASE_PATH || ''}${import.meta.env.BASE_URL || '/'}`
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(routerBase),
routes: [ routes: [
{ {
path: '/', path: '/',

View File

@@ -11,7 +11,6 @@ export const auditStore = defineStore('audit', {
filter: "", filter: "",
pageSize: 10, pageSize: 10,
pageOffset: 0, pageOffset: 0,
pages: [],
fetching: false, fetching: false,
}), }),
getters: { getters: {
@@ -41,33 +40,22 @@ export const auditStore = defineStore('audit', {
afterPageSizeChange() { afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes // reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0 this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
}, },
gotoPage(page) { gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
}, },
nextPage() { nextPage() {
if (this.hasNextPage) {
this.pageOffset += this.pageSize this.pageOffset += this.pageSize
}
this.calculatePages()
}, },
previousPage() { previousPage() {
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize this.pageOffset -= this.pageSize
}
this.calculatePages()
}, },
setEntries(entries) { setEntries(entries) {
this.entries = entries this.entries = entries
this.calculatePages()
this.fetching = false this.fetching = false
}, },
async LoadEntries() { async LoadEntries() {

View File

@@ -108,12 +108,19 @@ export const authStore = defineStore('auth',{
this.setUserInfo(null) this.setUserInfo(null)
this.ResetReturnUrl() // just to be sure^^ this.ResetReturnUrl() // just to be sure^^
let logoutResponse = null
try { try {
await apiWrapper.post(`/auth/logout`) logoutResponse = await apiWrapper.post(`/auth/logout`)
} catch (e) { } catch (e) {
console.log("Logout request failed:", e) console.log("Logout request failed:", e)
} }
const redirectUrl = logoutResponse?.RedirectUrl
if (redirectUrl) {
window.location.href = redirectUrl
return
}
notify({ notify({
title: "Logged Out", title: "Logged Out",
text: "Logout successful!", text: "Logout successful!",

View File

@@ -148,6 +148,18 @@ export const interfaceStore = defineStore('interfaces', {
throw new Error(error) throw new Error(error)
}) })
}, },
async CreateDefaultPeers(id) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/create-default-peers`)
.then(() => {
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async SaveConfiguration(id) { async SaveConfiguration(id) {
this.fetching = true this.fetching = true
return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/save-config`) return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/save-config`)

View File

@@ -19,7 +19,6 @@ export const peerStore = defineStore('peers', {
filter: "", filter: "",
pageSize: 10, pageSize: 10,
pageOffset: 0, pageOffset: 0,
pages: [],
fetching: false, fetching: false,
sortKey: 'IsConnected', // Default sort key sortKey: 'IsConnected', // Default sort key
sortOrder: -1, // 1 for ascending, -1 for descending sortOrder: -1, // 1 for ascending, -1 for descending
@@ -87,33 +86,22 @@ export const peerStore = defineStore('peers', {
afterPageSizeChange() { afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes // reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0 this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
}, },
gotoPage(page) { gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
}, },
nextPage() { nextPage() {
if (this.hasNextPage) {
this.pageOffset += this.pageSize this.pageOffset += this.pageSize
}
this.calculatePages()
}, },
previousPage() { previousPage() {
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize this.pageOffset -= this.pageSize
}
this.calculatePages()
}, },
setPeers(peers) { setPeers(peers) {
this.peers = peers this.peers = peers
this.calculatePages()
this.fetching = false this.fetching = false
this.trafficStats = {} this.trafficStats = {}
}, },

View File

@@ -20,7 +20,6 @@ export const profileStore = defineStore('profile', {
filter: "", filter: "",
pageSize: 10, pageSize: 10,
pageOffset: 0, pageOffset: 0,
pages: [],
fetching: false, fetching: false,
sortKey: 'IsConnected', // Default sort key sortKey: 'IsConnected', // Default sort key
sortOrder: -1, // 1 for ascending, -1 for descending sortOrder: -1, // 1 for ascending, -1 for descending
@@ -80,29 +79,19 @@ export const profileStore = defineStore('profile', {
afterPageSizeChange() { afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes // reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0 this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredPeerCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
}, },
gotoPage(page) { gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
}, },
nextPage() { nextPage() {
if (this.hasNextPage) {
this.pageOffset += this.pageSize this.pageOffset += this.pageSize
}
this.calculatePages()
}, },
previousPage() { previousPage() {
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize this.pageOffset -= this.pageSize
}
this.calculatePages()
}, },
setPeers(peers) { setPeers(peers) {
this.peers = peers this.peers = peers

View File

@@ -12,7 +12,6 @@ export const userStore = defineStore('users', {
filter: "", filter: "",
pageSize: 10, pageSize: 10,
pageOffset: 0, pageOffset: 0,
pages: [],
fetching: false, fetching: false,
}), }),
getters: { getters: {
@@ -43,33 +42,22 @@ export const userStore = defineStore('users', {
afterPageSizeChange() { afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes // reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0 this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
}, },
gotoPage(page) { gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
}, },
nextPage() { nextPage() {
if (this.hasNextPage) {
this.pageOffset += this.pageSize this.pageOffset += this.pageSize
}
this.calculatePages()
}, },
previousPage() { previousPage() {
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize this.pageOffset -= this.pageSize
}
this.calculatePages()
}, },
setUsers(users) { setUsers(users) {
this.users = users this.users = users
this.calculatePages()
this.fetching = false this.fetching = false
}, },
setUserPeers(peers) { setUserPeers(peers) {

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { onMounted } from "vue"; import { onMounted } from "vue";
import {auditStore} from "@/stores/audit"; import {auditStore} from "@/stores/audit";
import Pagination from "@/components/Pagination.vue";
const audit = auditStore() const audit = auditStore()
@@ -60,28 +61,24 @@ onMounted(async () => {
</table> </table>
</div> </div>
<hr> <hr>
<div class="mt-3">
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-12 col-md-6">
<ul class="pagination pagination-sm"> <Pagination
<li :class="{disabled:audit.pageOffset===0}" class="page-item"> :currentPage="audit.currentPage"
<a class="page-link" @click="audit.previousPage">&laquo;</a> :totalCount="audit.FilteredCount"
</li> :pageSize="audit.pageSize"
:hasNextPage="audit.hasNextPage"
<li v-for="page in audit.pages" :key="page" :class="{active:audit.currentPage===page}" class="page-item"> :hasPrevPage="audit.hasPrevPage"
<a class="page-link" @click="audit.gotoPage(page)">{{page}}</a> :onGotoPage="audit.gotoPage"
</li> :onNextPage="audit.nextPage"
:onPrevPage="audit.previousPage"
<li :class="{disabled:!audit.hasNextPage}" class="page-item"> />
<a class="page-link" @click="audit.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-6"> <div class="col-12 col-md-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label> <label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="audit.pageSize" class="form-select" @click="audit.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="audit.pageSize" class="form-select" @change="audit.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
@@ -92,5 +89,4 @@ onMounted(async () => {
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>

View File

@@ -1,9 +1,10 @@
<script setup> <script setup>
import PeerViewModal from "../components/PeerViewModal.vue"; import PeerViewModal from "@/components/PeerViewModal.vue";
import PeerEditModal from "../components/PeerEditModal.vue"; import PeerEditModal from "@/components/PeerEditModal.vue";
import PeerMultiCreateModal from "../components/PeerMultiCreateModal.vue"; import PeerMultiCreateModal from "@/components/PeerMultiCreateModal.vue";
import InterfaceEditModal from "../components/InterfaceEditModal.vue"; import InterfaceEditModal from "@/components/InterfaceEditModal.vue";
import InterfaceViewModal from "../components/InterfaceViewModal.vue"; import InterfaceViewModal from "@/components/InterfaceViewModal.vue";
import Pagination from "@/components/Pagination.vue";
import {computed, onMounted, ref} from "vue"; import {computed, onMounted, ref} from "vue";
import {peerStore} from "@/stores/peers"; import {peerStore} from "@/stores/peers";
@@ -482,26 +483,23 @@ onMounted(async () => {
<hr v-if="interfaces.Count!==0"> <hr v-if="interfaces.Count!==0">
<div v-if="interfaces.Count!==0" class="mt-3"> <div v-if="interfaces.Count!==0" class="mt-3">
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-12 col-md-6">
<ul class="pagination pagination-sm"> <Pagination
<li :class="{disabled:peers.pageOffset===0}" class="page-item"> :currentPage="peers.currentPage"
<a class="page-link" @click="peers.previousPage">&laquo;</a> :totalCount="peers.FilteredCount"
</li> :pageSize="peers.pageSize"
:hasNextPage="peers.hasNextPage"
<li v-for="page in peers.pages" :key="page" :class="{active:peers.currentPage===page}" class="page-item"> :hasPrevPage="peers.hasPrevPage"
<a class="page-link" @click="peers.gotoPage(page)">{{page}}</a> :onGotoPage="peers.gotoPage"
</li> :onNextPage="peers.nextPage"
:onPrevPage="peers.previousPage"
<li :class="{disabled:!peers.hasNextPage}" class="page-item"> />
<a class="page-link" @click="peers.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-6"> <div class="col-12 col-md-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label> <label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="peers.pageSize" class="form-select" @click="peers.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="peers.pageSize" class="form-select" @change="peers.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>

View File

@@ -83,7 +83,9 @@ const externalLogin = function (provider) {
console.log("Performing external login for provider", provider.Identifier); console.log("Performing external login for provider", provider.Identifier);
loggingIn.value = true; loggingIn.value = true;
console.log(router.currentRoute.value); console.log(router.currentRoute.value);
let currentUri = window.location.origin + "/#" + router.currentRoute.value.fullPath; const currentUrl = new URL(`${WGPORTAL_BASE_PATH || ''}${import.meta.env.BASE_URL || '/'}`, window.location.origin);
currentUrl.hash = router.currentRoute.value.fullPath;
let currentUri = currentUrl.toString();
let redirectUrl = `${WGPORTAL_BACKEND_BASE_URL}${provider.ProviderUrl}`; let redirectUrl = `${WGPORTAL_BACKEND_BASE_URL}${provider.ProviderUrl}`;
redirectUrl += "?redirect=true"; redirectUrl += "?redirect=true";
redirectUrl += "&return=" + encodeURIComponent(currentUri); redirectUrl += "&return=" + encodeURIComponent(currentUri);

View File

@@ -6,6 +6,7 @@ import { useI18n } from "vue-i18n";
import { profileStore } from "@/stores/profile"; import { profileStore } from "@/stores/profile";
import { peerStore } from "@/stores/peers"; import { peerStore } from "@/stores/peers";
import UserPeerEditModal from "@/components/UserPeerEditModal.vue"; import UserPeerEditModal from "@/components/UserPeerEditModal.vue";
import Pagination from "@/components/Pagination.vue";
import { settingsStore } from "@/stores/settings"; import { settingsStore } from "@/stores/settings";
import { humanFileSize } from "@/helpers/utils"; import { humanFileSize } from "@/helpers/utils";
@@ -66,7 +67,6 @@ onMounted(async () => {
await profile.LoadPeers() await profile.LoadPeers()
await profile.LoadStats() await profile.LoadStats()
await profile.LoadInterfaces() await profile.LoadInterfaces()
await profile.calculatePages(); // Forces to show initial page number
}) })
</script> </script>
@@ -185,28 +185,25 @@ onMounted(async () => {
<hr> <hr>
<div class="mt-3"> <div class="mt-3">
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-12 col-md-6">
<ul class="pagination pagination-sm"> <Pagination
<li :class="{ disabled: profile.pageOffset === 0 }" class="page-item"> :currentPage="profile.currentPage"
<a class="page-link" @click="profile.previousPage">&laquo;</a> :totalCount="profile.FilteredPeerCount"
</li> :pageSize="profile.pageSize"
:hasNextPage="profile.hasNextPage"
<li v-for="page in profile.pages" :key="page" :class="{ active: profile.currentPage === page }" class="page-item"> :hasPrevPage="profile.hasPrevPage"
<a class="page-link" @click="profile.gotoPage(page)">{{ page }}</a> :onGotoPage="profile.gotoPage"
</li> :onNextPage="profile.nextPage"
:onPrevPage="profile.previousPage"
<li :class="{ disabled: !profile.hasNextPage }" class="page-item"> />
<a class="page-link" @click="profile.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-6"> <div class="col-12 col-md-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector"> <label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">
{{ $t('general.pagination.size')}}: {{ $t('general.pagination.size')}}:
</label> </label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="profile.pageSize" class="form-select" @click="profile.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="profile.pageSize" class="form-select" @change="profile.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>

View File

@@ -1,8 +1,9 @@
<script setup> <script setup>
import {userStore} from "@/stores/users"; import {userStore} from "@/stores/users";
import {ref, onMounted, computed} from "vue"; import {ref, onMounted, computed} from "vue";
import UserEditModal from "../components/UserEditModal.vue"; import UserEditModal from "@/components/UserEditModal.vue";
import UserViewModal from "../components/UserViewModal.vue"; import UserViewModal from "@/components/UserViewModal.vue";
import Pagination from "@/components/Pagination.vue";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
const users = userStore() const users = userStore()
@@ -165,28 +166,24 @@ onMounted(() => {
</table> </table>
</div> </div>
<hr> <hr>
<div class="mt-3">
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-12 col-md-6">
<ul class="pagination pagination-sm"> <Pagination
<li :class="{disabled:users.pageOffset===0}" class="page-item"> :currentPage="users.currentPage"
<a class="page-link" @click="users.previousPage">&laquo;</a> :totalCount="users.FilteredCount"
</li> :pageSize="users.pageSize"
:hasNextPage="users.hasNextPage"
<li v-for="page in users.pages" :key="page" :class="{active:users.currentPage===page}" class="page-item"> :hasPrevPage="users.hasPrevPage"
<a class="page-link" @click="users.gotoPage(page)">{{page}}</a> :onGotoPage="users.gotoPage"
</li> :onNextPage="users.nextPage"
:onPrevPage="users.previousPage"
<li :class="{disabled:!users.hasNextPage}" class="page-item"> />
<a class="page-link" @click="users.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-6"> <div class="col-12 col-md-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label> <label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="users.pageSize" class="form-select" @click="users.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="users.pageSize" class="form-select" @change="users.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
@@ -197,5 +194,4 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>

74
go.mod
View File

@@ -1,16 +1,16 @@
module github.com/h44z/wg-portal module github.com/h44z/wg-portal
go 1.25.0 go 1.25.7
require ( require (
github.com/a8m/envsubst v1.4.3 github.com/a8m/envsubst v1.4.3
github.com/alexedwards/scs/v2 v2.9.0 github.com/alexedwards/scs/v2 v2.9.0
github.com/coreos/go-oidc/v3 v3.17.0 github.com/coreos/go-oidc/v3 v3.18.0
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-pkgz/routegroup v1.6.0 github.com/go-pkgz/routegroup v1.6.0
github.com/go-playground/validator/v10 v10.30.1 github.com/go-playground/validator/v10 v10.30.3
github.com/go-webauthn/webauthn v0.16.1 github.com/go-webauthn/webauthn v0.17.4
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/prometheus-community/pro-bing v0.8.0 github.com/prometheus-community/pro-bing v0.8.0
@@ -22,46 +22,48 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0 github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.5 github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1 github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.52.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.42.0 golang.org/x/sys v0.45.0
golang.org/x/text v0.37.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlserver v1.6.3 gorm.io/driver/sqlserver v1.6.3
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
pgregory.net/rapid v1.3.0
) )
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.3 // indirect github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect github.com/go-openapi/swag/loading v0.26.0 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-sql-driver/mysql v1.10.0 // indirect
github.com/go-test/deep v1.1.1 // indirect github.com/go-test/deep v1.1.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.2 // indirect github.com/go-webauthn/x v0.2.6 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect
@@ -69,41 +71,41 @@ require (
github.com/google/go-tpm v0.9.8 // indirect github.com/google/go-tpm v0.9.8 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/genetlink v1.4.0 // indirect
github.com/mdlayher/netlink v1.8.0 // indirect github.com/mdlayher/netlink v1.11.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect github.com/mdlayher/socket v0.6.0 // indirect
github.com/microsoft/go-mssqldb v1.9.6 // indirect github.com/microsoft/go-mssqldb v1.10.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib 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/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect github.com/prometheus/procfs v0.20.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // 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.36.0 // indirect
golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.54.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.45.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.68.0 // indirect modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect modernc.org/sqlite v1.50.1 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect
) )

196
go.sum
View File

@@ -3,29 +3,29 @@ filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q
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.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.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= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY= github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY=
@@ -38,8 +38,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -48,8 +48,8 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= 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.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
@@ -58,37 +58,37 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc= github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs= github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= 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.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4= github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4=
github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg= github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -97,18 +97,18 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= 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-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU= github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ= github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk= github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4= github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
@@ -143,8 +143,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 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/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -176,17 +176,17 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA=
github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM= github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHid9HK3594= github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw= github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4= github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= 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/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= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
@@ -195,6 +195,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
@@ -209,8 +211,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= 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/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.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
@@ -234,6 +236,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA= github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
@@ -258,8 +262,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -274,18 +278,16 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 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-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -303,8 +305,8 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -336,8 +338,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -366,16 +368,16 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
@@ -402,33 +404,35 @@ gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQ
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw= modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

View File

@@ -251,7 +251,7 @@ func (r *SqlRepo) migrate() error {
if existingSysStat.SchemaVersion == 1 { if existingSysStat.SchemaVersion == 1 {
const schemaVersion = 2 const schemaVersion = 2
// Preserve existing behavior for installations that had default-peer-creation enabled. // Preserve existing behavior for installations that had default-peer-creation enabled.
if r.cfg.Core.CreateDefaultPeer { if r.cfg.DefaultPeerCreationEnabled() {
err := r.db.Model(&domain.Interface{}). err := r.db.Model(&domain.Interface{}).
Where("type = ?", domain.InterfaceTypeServer). Where("type = ?", domain.InterfaceTypeServer).
Update("create_default_peer", true).Error Update("create_default_peer", true).Error

View File

@@ -617,18 +617,22 @@ func (c *PfsenseController) SaveInterface(
mutex.Lock() mutex.Lock()
defer mutex.Unlock() defer mutex.Unlock()
physicalInterface, err := c.getOrCreateInterface(ctx, id) physicalInterface, err := c.getInterface(ctx, id)
if err != nil { if err != nil {
return err return err
} }
deviceId := "" if physicalInterface == nil {
if physicalInterface.GetExtras() != nil { physicalInterface = &domain.PhysicalInterface{
if extras, ok := physicalInterface.GetExtras().(domain.PfsenseInterfaceExtras); ok { Identifier: id,
deviceId = extras.Id ImportSource: domain.ControllerTypePfsense,
DeviceType: domain.ControllerTypePfsense,
} }
physicalInterface.SetExtras(domain.PfsenseInterfaceExtras{})
} }
deviceId := physicalInterface.GetExtras().(domain.PfsenseInterfaceExtras).Id
if updateFunc != nil { if updateFunc != nil {
physicalInterface, err = updateFunc(physicalInterface) physicalInterface, err = updateFunc(physicalInterface)
if err != nil { if err != nil {
@@ -643,14 +647,14 @@ func (c *PfsenseController) SaveInterface(
} }
} }
if err := c.updateInterface(ctx, physicalInterface); err != nil { if err := c.createOrUpdateInterface(ctx, physicalInterface); err != nil {
return err return err
} }
return nil return nil
} }
func (c *PfsenseController) getOrCreateInterface( func (c *PfsenseController) getInterface(
ctx context.Context, ctx context.Context,
id domain.InterfaceIdentifier, id domain.InterfaceIdentifier,
) (*domain.PhysicalInterface, error) { ) (*domain.PhysicalInterface, error) {
@@ -659,50 +663,84 @@ func (c *PfsenseController) getOrCreateInterface(
"name": string(id), "name": string(id),
}, },
}) })
if wgReply.Status == lowlevel.PfsenseApiStatusOk && len(wgReply.Data) > 0 { if wgReply.Status != lowlevel.PfsenseApiStatusOk {
return nil, fmt.Errorf("failed to query interface %s: %v", id, wgReply.Error)
}
if len(wgReply.Data) == 0 {
return nil, nil
}
return c.loadInterfaceData(ctx, wgReply.Data[0]) return c.loadInterfaceData(ctx, wgReply.Data[0])
} }
// create a new tunnel if it does not exist type pfsenseWireGuardAddress struct {
// Actual endpoint: POST /api/v2/vpn/wireguard/tunnel (singular) Address string `json:"address"`
createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/tunnel", lowlevel.GenericJsonObject{ Mask int `json:"mask"`
"name": string(id), Descr string `json:"descr"`
})
if createReply.Status == lowlevel.PfsenseApiStatusOk {
return c.loadInterfaceData(ctx, createReply.Data)
} }
return nil, fmt.Errorf("failed to create interface %s: %v", id, createReply.Error) func cidrToPfsense(cidr *domain.Cidr) pfsenseWireGuardAddress {
return pfsenseWireGuardAddress{
Address: cidr.Addr,
Mask: cidr.NetLength,
// supported in pfsense, but not in wg-portal GUI
Descr: "",
}
} }
func (c *PfsenseController) updateInterface(ctx context.Context, pi *domain.PhysicalInterface) error { func (c *PfsenseController) createOrUpdateInterface(ctx context.Context, pi *domain.PhysicalInterface) error {
extras := pi.GetExtras().(domain.PfsenseInterfaceExtras) extras := pi.GetExtras().(domain.PfsenseInterfaceExtras)
interfaceId := extras.Id interfaceId := extras.Id
payload := lowlevel.GenericJsonObject{ payload := lowlevel.GenericJsonObject{
"name": string(pi.Identifier), "name": string(pi.Identifier),
"description": extras.Comment, "descr": extras.Comment,
"mtu": strconv.Itoa(pi.Mtu), "mtu": pi.Mtu,
"listenport": strconv.Itoa(pi.ListenPort), "listenport": strconv.Itoa(pi.ListenPort),
"privatekey": pi.KeyPair.PrivateKey, "privatekey": pi.KeyPair.PrivateKey,
"disabled": strconv.FormatBool(!pi.DeviceUp), "disabled": strconv.FormatBool(!pi.DeviceUp),
} }
// Add addresses if present addresses := make([]pfsenseWireGuardAddress, 0, len(pi.Addresses))
if len(pi.Addresses) > 0 {
addresses := make([]string, 0, len(pi.Addresses))
for _, addr := range pi.Addresses { for _, addr := range pi.Addresses {
addresses = append(addresses, addr.String()) addresses = append(addresses, cidrToPfsense(&addr))
} }
payload["addresses"] = strings.Join(addresses, ",") payload["addresses"] = addresses
if interfaceId == "" {
// Actual endpoint: POST /api/v2/vpn/wireguard/tunnel (singular)
createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/tunnel", payload)
if createReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to create interface %s: %v", pi.Identifier, createReply.Error)
}
// Capture the newly-assigned ID so callers see it
if newId := createReply.Data.GetString("id"); newId != "" {
extras.Id = newId
pi.SetExtras(extras)
}
if applyReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/apply", lowlevel.GenericJsonObject{}); applyReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to apply WireGuard changes after creating interface %s: %v",
pi.Identifier, applyReply.Error)
}
return nil
} }
// Actual endpoint: PATCH /api/v2/vpn/wireguard/tunnel?id={id} interfaceIdInt, err := strconv.Atoi(interfaceId)
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/tunnel?id="+interfaceId, payload) if err != nil {
return fmt.Errorf("invalid pfSense interface id %q for %s: %w", interfaceId, pi.Identifier, err)
}
payload["id"] = interfaceIdInt
// Actual endpoint: PATCH /api/v2/vpn/wireguard/tunnel
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/tunnel", payload)
if wgReply.Status != lowlevel.PfsenseApiStatusOk { if wgReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to update interface %s: %v", pi.Identifier, wgReply.Error) return fmt.Errorf("failed to update interface %s: %v", pi.Identifier, wgReply.Error)
} }
if applyReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/apply", lowlevel.GenericJsonObject{}); applyReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to apply WireGuard changes after updating interface %s: %v",
pi.Identifier, applyReply.Error)
}
return nil return nil
} }
@@ -726,8 +764,10 @@ func (c *PfsenseController) DeleteInterface(ctx context.Context, id domain.Inter
} }
interfaceId := wgReply.Data[0].GetString("id") interfaceId := wgReply.Data[0].GetString("id")
// Actual endpoint: DELETE /api/v2/vpn/wireguard/tunnel?id={id} // Actual endpoint: DELETE /api/v2/vpn/wireguard/tunnel?id={id}&apply=true
deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/tunnel?id="+interfaceId) deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/tunnel", &lowlevel.PfsenseRequestOptions{
Filters: map[string]string{"id": interfaceId, "apply": "true"},
})
if deleteReply.Status != lowlevel.PfsenseApiStatusOk { if deleteReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to delete WireGuard interface %s: %v", id, deleteReply.Error) return fmt.Errorf("failed to delete WireGuard interface %s: %v", id, deleteReply.Error)
} }
@@ -746,18 +786,22 @@ func (c *PfsenseController) SavePeer(
mutex.Lock() mutex.Lock()
defer mutex.Unlock() defer mutex.Unlock()
physicalPeer, err := c.getOrCreatePeer(ctx, deviceId, id) physicalPeer, err := c.getPeer(ctx, deviceId, id)
if err != nil { if err != nil {
return err return err
} }
peerId := "" if physicalPeer == nil {
if physicalPeer.GetExtras() != nil { physicalPeer = &domain.PhysicalPeer{
if extras, ok := physicalPeer.GetExtras().(domain.PfsensePeerExtras); ok { Identifier: id,
peerId = extras.Id KeyPair: domain.KeyPair{PublicKey: string(id)},
ImportSource: domain.ControllerTypePfsense,
} }
physicalPeer.SetExtras(domain.PfsensePeerExtras{})
} }
peerId := physicalPeer.GetExtras().(domain.PfsensePeerExtras).Id
physicalPeer, err = updateFunc(physicalPeer) physicalPeer, err = updateFunc(physicalPeer)
if err != nil { if err != nil {
return err return err
@@ -770,14 +814,14 @@ func (c *PfsenseController) SavePeer(
} }
} }
if err := c.updatePeer(ctx, deviceId, physicalPeer); err != nil { if err := c.createOrUpdatePeer(ctx, deviceId, physicalPeer); err != nil {
return err return err
} }
return nil return nil
} }
func (c *PfsenseController) getOrCreatePeer( func (c *PfsenseController) getPeer(
ctx context.Context, ctx context.Context,
deviceId domain.InterfaceIdentifier, deviceId domain.InterfaceIdentifier,
id domain.PeerIdentifier, id domain.PeerIdentifier,
@@ -790,7 +834,13 @@ func (c *PfsenseController) getOrCreatePeer(
"tun": string(deviceId), // Use "tun" field name as that's what the API uses "tun": string(deviceId), // Use "tun" field name as that's what the API uses
}, },
}) })
if wgReply.Status == lowlevel.PfsenseApiStatusOk && len(wgReply.Data) > 0 { if wgReply.Status != lowlevel.PfsenseApiStatusOk {
return nil, fmt.Errorf("failed to query peer %s for interface %s: %v", id, deviceId, wgReply.Error)
}
if len(wgReply.Data) == 0 {
return nil, nil
}
slog.Debug("found existing pfSense peer", "peer", id, "interface", deviceId) slog.Debug("found existing pfSense peer", "peer", id, "interface", deviceId)
existingPeer, err := c.convertWireGuardPeer(wgReply.Data[0]) existingPeer, err := c.convertWireGuardPeer(wgReply.Data[0])
if err != nil { if err != nil {
@@ -799,28 +849,7 @@ func (c *PfsenseController) getOrCreatePeer(
return &existingPeer, nil return &existingPeer, nil
} }
// create a new peer if it does not exist func (c *PfsenseController) createOrUpdatePeer(
// Actual endpoint: POST /api/v2/vpn/wireguard/peer (singular)
slog.Debug("creating new pfSense peer", "peer", id, "interface", deviceId)
createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/peer", lowlevel.GenericJsonObject{
"name": fmt.Sprintf("wg-%s", id[0:8]),
"interface": string(deviceId),
"publickey": string(id),
"allowedips": "0.0.0.0/0", // Use 0.0.0.0/0 as default, will be updated by updatePeer
})
if createReply.Status == lowlevel.PfsenseApiStatusOk {
newPeer, err := c.convertWireGuardPeer(createReply.Data)
if err != nil {
return nil, err
}
slog.Debug("successfully created pfSense peer", "peer", id, "interface", deviceId)
return &newPeer, nil
}
return nil, fmt.Errorf("failed to create peer %s for interface %s: %v", id, deviceId, createReply.Error)
}
func (c *PfsenseController) updatePeer(
ctx context.Context, ctx context.Context,
deviceId domain.InterfaceIdentifier, deviceId domain.InterfaceIdentifier,
pp *domain.PhysicalPeer, pp *domain.PhysicalPeer,
@@ -828,36 +857,74 @@ func (c *PfsenseController) updatePeer(
extras := pp.GetExtras().(domain.PfsensePeerExtras) extras := pp.GetExtras().(domain.PfsensePeerExtras)
peerId := extras.Id peerId := extras.Id
allowedIPsStr := domain.CidrsToString(pp.AllowedIPs)
slog.Debug("updating pfSense peer",
"peer", pp.Identifier,
"interface", deviceId,
"allowed-ips", allowedIPsStr,
"allowed-ips-count", len(pp.AllowedIPs),
"disabled", extras.Disabled)
payload := lowlevel.GenericJsonObject{ payload := lowlevel.GenericJsonObject{
"name": extras.Name, "tun": string(deviceId),
"description": extras.Comment, "descr": extras.Name,
"presharedkey": string(pp.PresharedKey), "presharedkey": string(pp.PresharedKey),
"publickey": pp.KeyPair.PublicKey, "publickey": pp.KeyPair.PublicKey,
"privatekey": pp.KeyPair.PrivateKey, "persistentkeepalive": pp.PersistentKeepalive,
"persistentkeepalive": strconv.Itoa(pp.PersistentKeepalive), "enabled": !extras.Disabled,
"disabled": strconv.FormatBool(extras.Disabled),
"allowedips": allowedIPsStr,
} }
if pp.Endpoint != "" { if pp.Endpoint != "" {
payload["endpoint"] = pp.Endpoint payload["endpoint"] = pp.Endpoint
} }
// Actual endpoint: PATCH /api/v2/vpn/wireguard/peer?id={id} allowedIps := make([]pfsenseWireGuardAddress, 0, len(pp.AllowedIPs))
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/peer?id="+peerId, payload) for _, addr := range pp.AllowedIPs {
allowedIps = append(allowedIps, cidrToPfsense(&addr))
}
payload["allowedips"] = allowedIps
if peerId == "" {
slog.Debug("creating new pfSense peer",
"peer", pp.Identifier,
"interface", deviceId,
"allowed-ips", domain.CidrsToString(pp.AllowedIPs),
"disabled", extras.Disabled)
// Actual endpoint: POST /api/v2/vpn/wireguard/peer (singular)
createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/peer", payload)
if createReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to create peer %s for interface %s: %v",
pp.Identifier, deviceId, createReply.Error)
}
if newId := createReply.Data.GetString("id"); newId != "" {
extras.Id = newId
pp.SetExtras(extras)
}
if applyReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/apply", lowlevel.GenericJsonObject{}); applyReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to apply WireGuard changes after creating peer %s on interface %s: %v",
pp.Identifier, deviceId, applyReply.Error)
}
slog.Debug("successfully created pfSense peer", "peer", pp.Identifier, "interface", deviceId)
return nil
}
slog.Debug("updating pfSense peer",
"peer", pp.Identifier,
"interface", deviceId,
"allowed-ips", domain.CidrsToString(pp.AllowedIPs),
"allowed-ips-count", len(pp.AllowedIPs),
"disabled", extras.Disabled)
peerIdInt, err := strconv.Atoi(peerId)
if err != nil {
return fmt.Errorf("invalid pfSense peer id %q for %s: %w", peerId, pp.Identifier, err)
}
payload["id"] = peerIdInt
// Actual endpoint: PATCH /api/v2/vpn/wireguard/peer
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/peer", payload)
if wgReply.Status != lowlevel.PfsenseApiStatusOk { if wgReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to update peer %s on interface %s: %v", pp.Identifier, deviceId, wgReply.Error) return fmt.Errorf("failed to update peer %s on interface %s: %v", pp.Identifier, deviceId, wgReply.Error)
} }
if applyReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/apply", lowlevel.GenericJsonObject{}); applyReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to apply WireGuard changes after updating peer %s on interface %s: %v",
pp.Identifier, deviceId, applyReply.Error)
}
if extras.Disabled { if extras.Disabled {
slog.Debug("successfully disabled pfSense peer", "peer", pp.Identifier, "interface", deviceId) slog.Debug("successfully disabled pfSense peer", "peer", pp.Identifier, "interface", deviceId)
} else { } else {
@@ -893,8 +960,10 @@ func (c *PfsenseController) DeletePeer(
} }
peerId := wgReply.Data[0].GetString("id") peerId := wgReply.Data[0].GetString("id")
// Actual endpoint: DELETE /api/v2/vpn/wireguard/peer?id={id} // Actual endpoint: DELETE /api/v2/vpn/wireguard/peer?id={id}&apply=true
deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/peer?id="+peerId) deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/peer", &lowlevel.PfsenseRequestOptions{
Filters: map[string]string{"id": peerId, "apply": "true"},
})
if deleteReply.Status != lowlevel.PfsenseApiStatusOk { if deleteReply.Status != lowlevel.PfsenseApiStatusOk {
return fmt.Errorf("failed to delete WireGuard peer %s for interface %s: %v", id, deviceId, deleteReply.Error) return fmt.Errorf("failed to delete WireGuard peer %s for interface %s: %v", id, deviceId, deleteReply.Error)
} }
@@ -976,4 +1045,3 @@ func (c *PfsenseController) PingAddresses(
} }
// endregion statistics-related // endregion statistics-related

View File

@@ -2,6 +2,7 @@ package backend
import ( import (
"context" "context"
"fmt"
"io" "io"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
@@ -18,6 +19,7 @@ type InterfaceServiceInterfaceManager interface {
DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error
PrepareInterface(ctx context.Context) (*domain.Interface, error) PrepareInterface(ctx context.Context) (*domain.Interface, error)
ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error
CreateDefaultPeers(ctx context.Context, id domain.InterfaceIdentifier) error
} }
type InterfaceServiceConfigFileManager interface { type InterfaceServiceConfigFileManager interface {
@@ -89,3 +91,10 @@ func (i InterfaceService) PersistInterfaceConfig(ctx context.Context, id domain.
func (i InterfaceService) ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error { func (i InterfaceService) ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error {
return i.interfaces.ApplyPeerDefaults(ctx, in) return i.interfaces.ApplyPeerDefaults(ctx, in)
} }
func (i InterfaceService) CreateDefaultPeers(ctx context.Context, id domain.InterfaceIdentifier) error {
if !i.cfg.DefaultPeerCreationEnabled() {
return fmt.Errorf("default peer creation is not enabled")
}
return i.interfaces.CreateDefaultPeers(ctx, id)
}

View File

@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-pkgz/routegroup" "github.com/go-pkgz/routegroup"
@@ -23,9 +24,11 @@ type AuthenticationService interface {
// PlainLogin authenticates a user with a username and password. // PlainLogin authenticates a user with a username and password.
PlainLogin(ctx context.Context, username, password string) (*domain.User, error) PlainLogin(ctx context.Context, username, password string) (*domain.User, error)
// OauthLoginStep1 initiates the OAuth login flow. // OauthLoginStep1 initiates the OAuth login flow.
OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce string, err error) OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce, codeVerifier string, err error)
// OauthLoginStep2 completes the OAuth login flow and logins the user in. // OauthLoginStep2 completes the OAuth login flow and logins the user in.
OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error) OauthLoginStep2(ctx context.Context, providerId, nonce, code, codeVerifier string) (*domain.User, string, error)
// OauthProviderLogoutUrl returns an IdP logout URL for the given provider if supported.
OauthProviderLogoutUrl(providerId, idTokenHint, postLogoutRedirectUri string) (string, bool)
} }
type WebAuthnService interface { type WebAuthnService interface {
@@ -199,9 +202,8 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
provider := request.Path(r, "provider") provider := request.Path(r, "provider")
var returnUrl *url.URL var returnUrl *url.URL
var returnParams string redirectToReturn := func(loginState string) {
redirectToReturn := func() { respond.Redirect(w, r, http.StatusFound, e.returnUrlWithLoginState(returnUrl, loginState))
respond.Redirect(w, r, http.StatusFound, returnUrl.String()+"?"+returnParams)
} }
if returnTo != "" { if returnTo != "" {
@@ -210,21 +212,18 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"}) model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"})
return return
} }
if u, err := url.Parse(returnTo); err == nil { u, err := url.Parse(returnTo)
returnUrl = u if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"})
return
} }
queryParams := returnUrl.Query() returnUrl = u
queryParams.Set("wgLoginState", "err") // by default, we set the state to error
returnUrl.RawQuery = "" // remove potential query params
returnParams = queryParams.Encode()
} }
if currentSession.LoggedIn { if currentSession.LoggedIn {
if autoRedirect && e.isValidReturnUrl(returnTo) { if autoRedirect && returnUrl != nil {
queryParams := returnUrl.Query() redirectToReturn("success")
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
} else { } else {
respond.JSON(w, http.StatusBadRequest, respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "already logged in"}) model.Error{Code: http.StatusBadRequest, Message: "already logged in"})
@@ -232,12 +231,12 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
return return
} }
authCodeUrl, state, nonce, err := e.authService.OauthLoginStep1(context.Background(), provider) authCodeUrl, state, nonce, codeVerifier, err := e.authService.OauthLoginStep1(context.Background(), provider)
if err != nil { if err != nil {
slog.Debug("failed to create oauth auth code URL", slog.Debug("failed to create oauth auth code URL",
"provider", provider, "error", err) "provider", provider, "error", err)
if autoRedirect && e.isValidReturnUrl(returnTo) { if autoRedirect && returnUrl != nil {
redirectToReturn() redirectToReturn("err")
} else { } else {
respond.JSON(w, http.StatusInternalServerError, respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
@@ -248,6 +247,7 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
authSession := e.session.GetData(r.Context()) authSession := e.session.GetData(r.Context())
authSession.OauthState = state authSession.OauthState = state
authSession.OauthNonce = nonce authSession.OauthNonce = nonce
authSession.OauthCodeVerifier = codeVerifier
authSession.OauthProvider = provider authSession.OauthProvider = provider
authSession.OauthReturnTo = returnTo authSession.OauthReturnTo = returnTo
e.session.SetData(r.Context(), authSession) e.session.SetData(r.Context(), authSession)
@@ -276,27 +276,19 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
currentSession := e.session.GetData(r.Context()) currentSession := e.session.GetData(r.Context())
var returnUrl *url.URL var returnUrl *url.URL
var returnParams string redirectToReturn := func(loginState string) {
redirectToReturn := func() { respond.Redirect(w, r, http.StatusFound, e.returnUrlWithLoginState(returnUrl, loginState))
respond.Redirect(w, r, http.StatusFound, returnUrl.String()+"?"+returnParams)
} }
if currentSession.OauthReturnTo != "" { if currentSession.OauthReturnTo != "" && e.isValidReturnUrl(currentSession.OauthReturnTo) {
if u, err := url.Parse(currentSession.OauthReturnTo); err == nil { if u, err := url.Parse(currentSession.OauthReturnTo); err == nil {
returnUrl = u returnUrl = u
} }
queryParams := returnUrl.Query()
queryParams.Set("wgLoginState", "err") // by default, we set the state to error
returnUrl.RawQuery = "" // remove potential query params
returnParams = queryParams.Encode()
} }
if currentSession.LoggedIn { if currentSession.LoggedIn {
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
queryParams := returnUrl.Query() redirectToReturn("success")
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
} else { } else {
respond.JSON(w, http.StatusBadRequest, model.Error{Message: "already logged in"}) respond.JSON(w, http.StatusBadRequest, model.Error{Message: "already logged in"})
} }
@@ -310,8 +302,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
if provider != currentSession.OauthProvider { if provider != currentSession.OauthProvider {
slog.Debug("invalid oauth provider in callback", slog.Debug("invalid oauth provider in callback",
"expected", currentSession.OauthProvider, "got", provider, "state", oauthState) "expected", currentSession.OauthProvider, "got", provider, "state", oauthState)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
redirectToReturn() redirectToReturn("err")
} else { } else {
respond.JSON(w, http.StatusBadRequest, respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid oauth provider"}) model.Error{Code: http.StatusBadRequest, Message: "invalid oauth provider"})
@@ -321,8 +313,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
if oauthState != currentSession.OauthState { if oauthState != currentSession.OauthState {
slog.Debug("invalid oauth state in callback", slog.Debug("invalid oauth state in callback",
"expected", currentSession.OauthState, "got", oauthState, "provider", provider) "expected", currentSession.OauthState, "got", oauthState, "provider", provider)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
redirectToReturn() redirectToReturn("err")
} else { } else {
respond.JSON(w, http.StatusBadRequest, respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid oauth state"}) model.Error{Code: http.StatusBadRequest, Message: "invalid oauth state"})
@@ -331,14 +323,14 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
} }
loginCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // avoid long waits loginCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // avoid long waits
user, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce, user, idTokenHint, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce,
oauthCode) oauthCode, currentSession.OauthCodeVerifier)
cancel() cancel()
if err != nil { if err != nil {
slog.Debug("failed to process oauth code", slog.Debug("failed to process oauth code",
"provider", provider, "state", oauthState, "error", err) "provider", provider, "state", oauthState, "error", err)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
redirectToReturn() redirectToReturn("err")
} else { } else {
respond.JSON(w, http.StatusUnauthorized, respond.JSON(w, http.StatusUnauthorized,
model.Error{Code: http.StatusUnauthorized, Message: err.Error()}) model.Error{Code: http.StatusUnauthorized, Message: err.Error()})
@@ -346,20 +338,17 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
return return
} }
e.setAuthenticatedUser(r, user) e.setAuthenticatedUser(r, user, provider, idTokenHint)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
queryParams := returnUrl.Query() redirectToReturn("success")
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
} else { } else {
respond.JSON(w, http.StatusOK, user) respond.JSON(w, http.StatusOK, user)
} }
} }
} }
func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User) { func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User, oauthProvider, idTokenHint string) {
// start a fresh session // start a fresh session
e.session.DestroyData(r.Context()) e.session.DestroyData(r.Context())
@@ -374,8 +363,10 @@ func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User) {
currentSession.OauthState = "" currentSession.OauthState = ""
currentSession.OauthNonce = "" currentSession.OauthNonce = ""
currentSession.OauthProvider = "" currentSession.OauthCodeVerifier = ""
currentSession.OauthProvider = oauthProvider
currentSession.OauthReturnTo = "" currentSession.OauthReturnTo = ""
currentSession.OauthIdToken = idTokenHint
e.session.SetData(r.Context(), currentSession) e.session.SetData(r.Context(), currentSession)
} }
@@ -418,7 +409,7 @@ func (e AuthEndpoint) handleLoginPost() http.HandlerFunc {
return return
} }
e.setAuthenticatedUser(r, user) e.setAuthenticatedUser(r, user, "", "")
respond.JSON(w, http.StatusOK, user) respond.JSON(w, http.StatusOK, user)
} }
@@ -430,19 +421,29 @@ func (e AuthEndpoint) handleLoginPost() http.HandlerFunc {
// @Tags Authentication // @Tags Authentication
// @Summary Get all available external login providers. // @Summary Get all available external login providers.
// @Produce json // @Produce json
// @Success 200 {object} model.Error // @Success 200 {object} model.LogoutResponse
// @Router /auth/logout [post] // @Router /auth/logout [post]
func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc { func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
currentSession := e.session.GetData(r.Context()) currentSession := e.session.GetData(r.Context())
if !currentSession.LoggedIn { // Not logged in if !currentSession.LoggedIn { // Not logged in
respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, Message: "not logged in"}) respond.JSON(w, http.StatusOK, model.LogoutResponse{Message: "not logged in"})
return return
} }
postLogoutRedirectUri := e.frontendUrl("/login")
var redirectUrl *string
if currentSession.OauthProvider != "" {
if idpLogoutUrl, ok := e.authService.OauthProviderLogoutUrl(currentSession.OauthProvider,
currentSession.OauthIdToken, postLogoutRedirectUri); ok {
redirectUrl = &idpLogoutUrl
}
}
e.session.DestroyData(r.Context()) e.session.DestroyData(r.Context())
respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, Message: "logout ok"}) respond.JSON(w, http.StatusOK, model.LogoutResponse{Message: "logout ok", RedirectUrl: redirectUrl})
} }
} }
@@ -462,9 +463,60 @@ func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
return false return false
} }
if e.cfg.Web.BasePath != "" {
expectedPath := e.cfg.Web.BasePath + "/app"
if returnUrlParsed.Path != expectedPath && !strings.HasPrefix(returnUrlParsed.Path, expectedPath+"/") {
return false
}
}
return true return true
} }
func (e AuthEndpoint) frontendUrl(route string) string {
frontendUrl := e.cfg.Web.ExternalUrl + e.cfg.Web.BasePath + "/app/"
if route != "" {
frontendUrl += "#" + route
}
return frontendUrl
}
func (e AuthEndpoint) returnUrlWithLoginState(returnUrl *url.URL, loginState string) string {
if returnUrl == nil {
frontendURL, err := url.Parse(e.frontendUrl("/login"))
if err != nil {
return e.frontendUrl("/login")
}
returnUrl = frontendURL
}
redirectUrl := *returnUrl
if redirectUrl.Fragment != "" {
fragmentPath := redirectUrl.Fragment
fragmentQuery := ""
if queryStart := strings.Index(fragmentPath, "?"); queryStart >= 0 {
fragmentQuery = fragmentPath[queryStart+1:]
fragmentPath = fragmentPath[:queryStart]
}
queryParams, err := url.ParseQuery(fragmentQuery)
if err != nil {
queryParams = url.Values{}
}
queryParams.Set("wgLoginState", loginState)
redirectUrl.Fragment = fragmentPath + "?" + queryParams.Encode()
return redirectUrl.String()
}
queryParams := redirectUrl.Query()
queryParams.Set("wgLoginState", loginState)
redirectUrl.RawQuery = queryParams.Encode()
return redirectUrl.String()
}
// handleWebAuthnCredentialsGet returns a gorm Handler function. // handleWebAuthnCredentialsGet returns a gorm Handler function.
// //
// @ID auth_handleWebAuthnCredentialsGet // @ID auth_handleWebAuthnCredentialsGet
@@ -693,7 +745,7 @@ func (e AuthEndpoint) handleWebAuthnLoginFinish() http.HandlerFunc {
return return
} }
e.setAuthenticatedUser(r, user) e.setAuthenticatedUser(r, user, "", "")
respond.JSON(w, http.StatusOK, model.NewUser(user, false)) respond.JSON(w, http.StatusOK, model.NewUser(user, false))
} }

View File

@@ -0,0 +1,114 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/h44z/wg-portal/internal/config"
)
type testSession struct {
data SessionData
}
func (s *testSession) SetData(_ context.Context, val SessionData) {
s.data = val
}
func (s *testSession) GetData(_ context.Context) SessionData {
return s.data
}
func (s *testSession) DestroyData(_ context.Context) {
s.data = SessionData{}
}
func newBasePathAuthEndpoint(session Session) AuthEndpoint {
return AuthEndpoint{
cfg: &config.Config{
Web: config.WebConfig{
ExternalUrl: "https://wg.example.com",
BasePath: "/subpath",
},
},
session: session,
}
}
func TestAuthEndpointIsValidReturnUrlRequiresBasePathApp(t *testing.T) {
ep := newBasePathAuthEndpoint(&testSession{})
valid := []string{
"https://wg.example.com/subpath/app/#/login",
"https://wg.example.com/subpath/app/#/login?all=true",
"https://wg.example.com/subpath/app/?beforeHash=true#/login",
}
for _, returnURL := range valid {
if !ep.isValidReturnUrl(returnURL) {
t.Fatalf("expected return URL to be valid: %s", returnURL)
}
}
invalid := []string{
"https://wg.example.com/#/login",
"https://wg.example.com/subpath/#/login",
"https://other.example.com/subpath/app/#/login",
}
for _, returnURL := range invalid {
if ep.isValidReturnUrl(returnURL) {
t.Fatalf("expected return URL to be invalid: %s", returnURL)
}
}
}
func TestAuthEndpointOauthCallbackRedirectsToBasePathHashRoute(t *testing.T) {
session := &testSession{data: SessionData{
LoggedIn: true,
OauthReturnTo: "https://wg.example.com/subpath/app/#/login",
}}
ep := newBasePathAuthEndpoint(session)
req := httptest.NewRequest(http.MethodGet, "/api/v0/auth/login/google/callback", nil)
req.SetPathValue("provider", "google")
res := httptest.NewRecorder()
ep.handleOauthCallbackGet().ServeHTTP(res, req)
if res.Code != http.StatusFound {
t.Fatalf("expected status %d, got %d", http.StatusFound, res.Code)
}
if got, want := res.Header().Get("Location"), "https://wg.example.com/subpath/app/#/login?wgLoginState=success"; got != want {
t.Fatalf("expected redirect %q, got %q", want, got)
}
}
func TestAuthEndpointReturnUrlWithLoginStatePreservesHashQuery(t *testing.T) {
session := &testSession{data: SessionData{
LoggedIn: true,
OauthReturnTo: "https://wg.example.com/subpath/app/#/login?all=true",
}}
ep := newBasePathAuthEndpoint(session)
req := httptest.NewRequest(http.MethodGet, "/api/v0/auth/login/google/callback", nil)
req.SetPathValue("provider", "google")
res := httptest.NewRecorder()
ep.handleOauthCallbackGet().ServeHTTP(res, req)
if res.Code != http.StatusFound {
t.Fatalf("expected status %d, got %d", http.StatusFound, res.Code)
}
if got, want := res.Header().Get("Location"), "https://wg.example.com/subpath/app/#/login?all=true&wgLoginState=success"; got != want {
t.Fatalf("expected redirect %q, got %q", want, got)
}
}
func TestAuthEndpointFrontendUrlUsesBasePathAppMount(t *testing.T) {
ep := newBasePathAuthEndpoint(&testSession{})
if got, want := ep.frontendUrl("/login"), "https://wg.example.com/subpath/app/#/login"; got != want {
t.Fatalf("expected frontend URL %q, got %q", want, got)
}
}

View File

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

View File

@@ -33,6 +33,8 @@ type InterfaceService interface {
PersistInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) error PersistInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) error
// ApplyPeerDefaults applies the peer defaults to all peers of the given interface. // ApplyPeerDefaults applies the peer defaults to all peers of the given interface.
ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error
// CreateDefaultPeers creates default peers for all existing users on the given interface.
CreateDefaultPeers(ctx context.Context, id domain.InterfaceIdentifier) error
} }
type InterfaceEndpoint struct { type InterfaceEndpoint struct {
@@ -73,6 +75,7 @@ func (e InterfaceEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup.HandleFunc("GET /config/{id}", e.handleConfigGet()) apiGroup.HandleFunc("GET /config/{id}", e.handleConfigGet())
apiGroup.HandleFunc("POST /{id}/save-config", e.handleSaveConfigPost()) apiGroup.HandleFunc("POST /{id}/save-config", e.handleSaveConfigPost())
apiGroup.HandleFunc("POST /{id}/apply-peer-defaults", e.handleApplyPeerDefaultsPost()) apiGroup.HandleFunc("POST /{id}/apply-peer-defaults", e.handleApplyPeerDefaultsPost())
apiGroup.HandleFunc("POST /{id}/create-default-peers", e.handleCreateDefaultPeersPost())
apiGroup.HandleFunc("GET /peers/{id}", e.handlePeersGet()) apiGroup.HandleFunc("GET /peers/{id}", e.handlePeersGet())
} }
@@ -421,3 +424,34 @@ func (e InterfaceEndpoint) handleApplyPeerDefaultsPost() http.HandlerFunc {
respond.Status(w, http.StatusNoContent) respond.Status(w, http.StatusNoContent)
} }
} }
// handleCreateDefaultPeersPost returns a gorm Handler function.
//
// @ID interfaces_handleCreateDefaultPeersPost
// @Tags Interface
// @Summary Create default peers for all existing users on the given interface.
// @Produce json
// @Param id path string true "The interface identifier"
// @Success 204 "No content if creating the default peers was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /interface/{id}/create-default-peers [post]
func (e InterfaceEndpoint) handleCreateDefaultPeersPost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := Base64UrlDecode(request.Path(r, "id"))
if id == "" {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
return
}
if err := e.interfaceService.CreateDefaultPeers(r.Context(), domain.InterfaceIdentifier(id)); err != nil {
respond.JSON(w, http.StatusInternalServerError, model.Error{
Code: http.StatusInternalServerError, Message: err.Error(),
})
return
}
respond.Status(w, http.StatusNoContent)
}
}

View File

@@ -3,8 +3,10 @@ package handlers
import ( import (
"context" "context"
"net/http" "net/http"
"net/url"
"strings" "strings"
"sync" "sync"
"time"
"github.com/go-pkgz/routegroup" "github.com/go-pkgz/routegroup"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@@ -14,49 +16,83 @@ import (
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
const (
websocketPeerUserIdentifierCacheTTL = 90 * time.Second
websocketPeerUserIdentifierCacheCleanupInterval = websocketPeerUserIdentifierCacheTTL * 2
)
type WebsocketEventBus interface { type WebsocketEventBus interface {
Subscribe(topic string, fn any) error Subscribe(topic string, fn any) error
Unsubscribe(topic string, fn any) error Unsubscribe(topic string, fn any) error
} }
type WebsocketPeerService interface {
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error)
}
type WebsocketEndpoint struct { type WebsocketEndpoint struct {
authenticator Authenticator authenticator Authenticator
bus WebsocketEventBus bus WebsocketEventBus
peerService WebsocketPeerService
upgrader websocket.Upgrader upgrader websocket.Upgrader
ownershipCache map[domain.PeerIdentifier]peerUserIdentifierCacheEntry
ownershipCacheMux sync.Mutex
} }
func NewWebsocketEndpoint(cfg *config.Config, auth Authenticator, bus WebsocketEventBus) *WebsocketEndpoint { func NewWebsocketEndpoint(
cfg *config.Config,
auth Authenticator,
bus WebsocketEventBus,
peerService WebsocketPeerService,
) *WebsocketEndpoint {
return &WebsocketEndpoint{ return &WebsocketEndpoint{
authenticator: auth, authenticator: auth,
bus: bus, bus: bus,
peerService: peerService,
upgrader: websocket.Upgrader{ upgrader: websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 1024,
WriteBufferSize: 1024, WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin") return matchOrigin(cfg.Web.ExternalUrl, r.Header.Get("Origin"))
return strings.HasPrefix(origin, cfg.Web.ExternalUrl)
}, },
}, },
ownershipCache: make(map[domain.PeerIdentifier]peerUserIdentifierCacheEntry),
ownershipCacheMux: sync.Mutex{},
} }
} }
func (e WebsocketEndpoint) GetName() string { func (e *WebsocketEndpoint) GetName() string {
return "WebsocketEndpoint" return "WebsocketEndpoint"
} }
func (e WebsocketEndpoint) RegisterRoutes(g *routegroup.Bundle) { func (e *WebsocketEndpoint) RegisterRoutes(g *routegroup.Bundle) {
g.With(e.authenticator.LoggedIn()).HandleFunc("GET /ws", e.handleWebsocket()) g.With(e.authenticator.LoggedIn()).HandleFunc("GET /ws", e.handleWebsocket())
} }
// StartBackgroundJobs starts background jobs like the expired peers check.
// This method is non-blocking.
func (e *WebsocketEndpoint) StartBackgroundJobs(ctx context.Context) {
go e.startOwnerCacheCleanup(ctx)
}
// wsMessage represents a message sent over websocket to the frontend // wsMessage represents a message sent over websocket to the frontend
type wsMessage struct { type wsMessage struct {
Type string `json:"type"` // either "peer_stats" or "interface_stats" Type string `json:"type"` // either "peer_stats" or "interface_stats"
Data any `json:"data"` // domain.TrafficDelta Data any `json:"data"` // domain.TrafficDelta
} }
func (e WebsocketEndpoint) handleWebsocket() http.HandlerFunc { // peerUserIdentifierCacheEntry is a cache entry object that reduces database load when checking peer ownership.
type peerUserIdentifierCacheEntry struct {
userIdentifier domain.UserIdentifier
expiresAt time.Time
}
func (e *WebsocketEndpoint) handleWebsocket() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
userInfo := domain.GetUserInfo(r.Context())
conn, err := e.upgrader.Upgrade(w, r, nil) conn, err := e.upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
return return
@@ -74,9 +110,29 @@ func (e WebsocketEndpoint) handleWebsocket() http.HandlerFunc {
} }
peerStatsHandler := func(status domain.TrafficDelta) { peerStatsHandler := func(status domain.TrafficDelta) {
if !userInfo.IsAdmin {
// lookup peer user-info to validate ownership
peerUserIdentifier, err := e.getPeerUserIdentifier(ctx, domain.PeerIdentifier(status.EntityId))
if err != nil {
return
}
if peerUserIdentifier == "" {
return // if peer is not assigned to any user, dont send stats
}
if peerUserIdentifier != userInfo.Id {
return // only expose stats for own peers
}
}
_ = writeJSON(wsMessage{Type: "peer_stats", Data: status}) _ = writeJSON(wsMessage{Type: "peer_stats", Data: status})
} }
interfaceStatsHandler := func(status domain.TrafficDelta) { interfaceStatsHandler := func(status domain.TrafficDelta) {
if !userInfo.IsAdmin {
return // interface stats will only be exposed to admins
}
_ = writeJSON(wsMessage{Type: "interface_stats", Data: status}) _ = writeJSON(wsMessage{Type: "interface_stats", Data: status})
} }
@@ -98,3 +154,72 @@ func (e WebsocketEndpoint) handleWebsocket() http.HandlerFunc {
<-ctx.Done() <-ctx.Done()
} }
} }
func (e *WebsocketEndpoint) getPeerUserIdentifier(
ctx context.Context,
peerIdentifier domain.PeerIdentifier,
) (domain.UserIdentifier, error) {
now := time.Now()
e.ownershipCacheMux.Lock()
entry, ok := e.ownershipCache[peerIdentifier]
if ok && now.Before(entry.expiresAt) {
e.ownershipCacheMux.Unlock()
return entry.userIdentifier, nil
}
e.ownershipCacheMux.Unlock()
peer, err := e.peerService.GetPeer(ctx, peerIdentifier)
if err != nil {
return "", err
}
e.ownershipCacheMux.Lock()
defer e.ownershipCacheMux.Unlock()
e.ownershipCache[peerIdentifier] = peerUserIdentifierCacheEntry{
userIdentifier: peer.UserIdentifier,
expiresAt: now.Add(websocketPeerUserIdentifierCacheTTL),
}
return peer.UserIdentifier, nil
}
func (e *WebsocketEndpoint) startOwnerCacheCleanup(ctx context.Context) {
ticker := time.NewTicker(websocketPeerUserIdentifierCacheCleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
e.cleanupOwnerCache(now)
}
}
}
func (e *WebsocketEndpoint) cleanupOwnerCache(now time.Time) {
e.ownershipCacheMux.Lock()
defer e.ownershipCacheMux.Unlock()
for peerIdentifier, entry := range e.ownershipCache {
if !now.Before(entry.expiresAt) {
delete(e.ownershipCache, peerIdentifier)
}
}
}
func matchOrigin(externalBaseUrl, origin string) bool {
originURL, err := url.Parse(origin)
if err != nil {
return false
}
externalURL, err := url.Parse(externalBaseUrl)
if err != nil {
return false
}
return originURL.Scheme == externalURL.Scheme &&
strings.EqualFold(originURL.Host, externalURL.Host)
}

View File

@@ -0,0 +1,249 @@
package handlers
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/websocket"
evbus "github.com/vardius/message-bus"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
// region test-helper
type websocketTestPeerService struct {
peers map[domain.PeerIdentifier]*domain.Peer
}
func (s websocketTestPeerService) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) {
peer, ok := s.peers[id]
if !ok {
return nil, errors.New("peer not found")
}
return peer, nil
}
func newTestWebsocketConnection(
t *testing.T,
bus evbus.MessageBus,
userInfo *domain.ContextUserInfo,
peers map[domain.PeerIdentifier]*domain.Peer,
) (*websocket.Conn, func()) {
t.Helper()
cfg := &config.Config{}
endpoint := NewWebsocketEndpoint(cfg, nil, bus, websocketTestPeerService{peers: peers})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(domain.SetUserInfo(r.Context(), userInfo))
endpoint.handleWebsocket()(w, r)
}))
cfg.Web.ExternalUrl = server.URL
wsURL := "ws" + server.URL[len("http"):]
conn, _, err := websocket.DefaultDialer.Dial(wsURL, http.Header{"Origin": []string{server.URL}})
if err != nil {
server.Close()
t.Fatalf("failed to dial websocket: %v", err)
}
cleanup := func() {
conn.Close()
server.Close()
}
return conn, cleanup
}
func assertWebsocketMessage(t *testing.T, conn *websocket.Conn, messageType string, entityId string) {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatalf("failed to set read deadline: %v", err)
}
var message wsMessage
if err := conn.ReadJSON(&message); err != nil {
t.Fatalf("failed to read websocket message: %v", err)
}
if message.Type != messageType {
t.Fatalf("unexpected message type: got %q, want %q", message.Type, messageType)
}
data, ok := message.Data.(map[string]any)
if !ok {
t.Fatalf("unexpected message data type: %T", message.Data)
}
if data["EntityId"] != entityId {
t.Fatalf("unexpected entity id: got %v, want %q", data["EntityId"], entityId)
}
}
func assertNoWebsocketMessage(t *testing.T, conn *websocket.Conn) {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
t.Fatalf("failed to set read deadline: %v", err)
}
var message wsMessage
if err := conn.ReadJSON(&message); err == nil {
t.Fatalf("unexpected websocket message: %+v", message)
}
}
// endregion test-helper
func TestWebsocketEndpointAllowsOwnPeerStatsForNonAdmin(t *testing.T) {
bus := evbus.New(10)
conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"},
map[domain.PeerIdentifier]*domain.Peer{
"own-peer": {Identifier: "own-peer", UserIdentifier: "user-a"},
})
defer cleanup()
bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "own-peer", BytesReceivedPerSecond: 1})
assertWebsocketMessage(t, conn, "peer_stats", "own-peer")
}
func TestWebsocketEndpointCleansExpiredPeerUserIdentifierCache(t *testing.T) {
now := time.Now()
endpoint := &WebsocketEndpoint{
ownershipCache: map[domain.PeerIdentifier]peerUserIdentifierCacheEntry{
"expired-peer": {
userIdentifier: "user-a",
expiresAt: now.Add(-time.Second),
},
"active-peer": {
userIdentifier: "user-b",
expiresAt: now.Add(time.Second),
},
},
}
endpoint.cleanupOwnerCache(now)
if _, ok := endpoint.ownershipCache["expired-peer"]; ok {
t.Fatal("expired peer cache entry was not removed")
}
if _, ok := endpoint.ownershipCache["active-peer"]; !ok {
t.Fatal("active peer cache entry was removed")
}
}
func TestWebsocketEndpointFiltersOtherPeerStatsForNonAdmin(t *testing.T) {
bus := evbus.New(10)
conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"},
map[domain.PeerIdentifier]*domain.Peer{
"other-peer": {Identifier: "other-peer", UserIdentifier: "user-b"},
})
defer cleanup()
bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "other-peer", BytesReceivedPerSecond: 1})
assertNoWebsocketMessage(t, conn)
}
func TestWebsocketEndpointFiltersUnknownPeerStatsForNonAdmin(t *testing.T) {
bus := evbus.New(10)
conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"},
map[domain.PeerIdentifier]*domain.Peer{
"other-peer": {Identifier: "other-peer", UserIdentifier: ""},
})
defer cleanup()
bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "other-peer", BytesReceivedPerSecond: 1})
assertNoWebsocketMessage(t, conn)
}
func TestWebsocketEndpointFiltersUnknownPeerStatsForNonAdmin2(t *testing.T) {
bus := evbus.New(10)
conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"}, nil)
defer cleanup()
bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "unknown-peer", BytesReceivedPerSecond: 1})
assertNoWebsocketMessage(t, conn)
}
func TestWebsocketEndpointFiltersInterfaceStatsForNonAdmin(t *testing.T) {
bus := evbus.New(10)
conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "user-a"}, nil)
defer cleanup()
bus.Publish(app.TopicInterfaceStatsUpdated, domain.TrafficDelta{EntityId: "wg0", BytesReceivedPerSecond: 1})
assertNoWebsocketMessage(t, conn)
}
func TestWebsocketEndpointAllowsAllStatsForAdmin(t *testing.T) {
bus := evbus.New(10)
conn, cleanup := newTestWebsocketConnection(t, bus, &domain.ContextUserInfo{Id: "admin", IsAdmin: true}, nil)
defer cleanup()
bus.Publish(app.TopicPeerStatsUpdated, domain.TrafficDelta{EntityId: "other-peer", BytesReceivedPerSecond: 1})
assertWebsocketMessage(t, conn, "peer_stats", "other-peer")
bus.Publish(app.TopicInterfaceStatsUpdated, domain.TrafficDelta{EntityId: "wg0", BytesReceivedPerSecond: 1})
assertWebsocketMessage(t, conn, "interface_stats", "wg0")
}
func Test_matchOrigin(t *testing.T) {
tests := []struct {
name string
externalBaseUrl string
origin string
want bool
}{
{
name: "matching origin",
externalBaseUrl: "https://example.com",
origin: "https://example.com",
want: true,
},
{
name: "matching origin with path",
externalBaseUrl: "https://example.com/app1",
origin: "https://example.com/app2",
want: true,
},
{
name: "non-matching origin with different host",
externalBaseUrl: "https://example.com",
origin: "https://example.com.malicious.com",
want: false,
},
{
name: "non-matching origin with different scheme",
externalBaseUrl: "https://example.com",
origin: "http://example.com",
want: false,
},
{
name: "invalid origin URL",
externalBaseUrl: "https://example.com",
origin: "://invalid-url",
want: false,
},
{
name: "invalid externalBaseUrl",
externalBaseUrl: "://invalid-url",
origin: "https://example.com",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchOrigin(tt.externalBaseUrl, tt.origin)
if got != tt.want {
t.Errorf("matchOrigin() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -28,8 +28,10 @@ type SessionData struct {
OauthState string OauthState string
OauthNonce string OauthNonce string
OauthCodeVerifier string
OauthProvider string OauthProvider string
OauthReturnTo string OauthReturnTo string
OauthIdToken string
WebAuthnData string WebAuthnData string
@@ -87,7 +89,9 @@ func (s *SessionWrapper) defaultSessionData() SessionData {
Email: "", Email: "",
OauthState: "", OauthState: "",
OauthNonce: "", OauthNonce: "",
OauthCodeVerifier: "",
OauthProvider: "", OauthProvider: "",
OauthReturnTo: "", OauthReturnTo: "",
OauthIdToken: "",
} }
} }

View File

@@ -45,6 +45,11 @@ type OauthInitiationResponse struct {
State string State string
} }
type LogoutResponse struct {
Message string `json:"Message"`
RedirectUrl *string `json:"RedirectUrl,omitempty"`
}
type WebAuthnCredentialRequest struct { type WebAuthnCredentialRequest struct {
Name string `json:"Name"` Name string `json:"Name"`
} }

View File

@@ -47,6 +47,11 @@ const (
AuthenticatorTypeOidc AuthenticatorType = "oidc" AuthenticatorTypeOidc AuthenticatorType = "oidc"
) )
const (
pkceMethodS256 = "S256" // SHA-256 hashing
pkceMethodPlain = "plain" // plain text
)
// AuthenticatorOauth is the interface for all OAuth authenticators. // AuthenticatorOauth is the interface for all OAuth authenticators.
type AuthenticatorOauth interface { type AuthenticatorOauth interface {
// GetName returns the name of the authenticator. // GetName returns the name of the authenticator.
@@ -65,6 +70,15 @@ type AuthenticatorOauth interface {
RegistrationEnabled() bool RegistrationEnabled() bool
// GetAllowedDomains returns the list of whitelisted domains // GetAllowedDomains returns the list of whitelisted domains
GetAllowedDomains() []string GetAllowedDomains() []string
// GetAllowedUserGroups returns the list of whitelisted user groups.
// If non-empty, at least one user group must match.
GetAllowedUserGroups() []string
// GetLogoutUrl returns an IdP logout URL if supported by the provider.
GetLogoutUrl(idTokenHint, postLogoutRedirectUri string) (string, bool)
// PKCEAuthCodeOptions returns PKCE options for the authorization request and the verifier for the token exchange.
PKCEAuthCodeOptions() ([]oauth2.AuthCodeOption, string)
// PKCETokenOptions returns PKCE options for the token exchange.
PKCETokenOptions(verifier string) []oauth2.AuthCodeOption
} }
// AuthenticatorLdap is the interface for all LDAP authenticators. // AuthenticatorLdap is the interface for all LDAP authenticators.
@@ -443,30 +457,34 @@ func (a *Authenticator) passwordAuthentication(
// OauthLoginStep1 starts the oauth authentication flow by returning the authentication URL, state and nonce. // OauthLoginStep1 starts the oauth authentication flow by returning the authentication URL, state and nonce.
func (a *Authenticator) OauthLoginStep1(_ context.Context, providerId string) ( func (a *Authenticator) OauthLoginStep1(_ context.Context, providerId string) (
authCodeUrl, state, nonce string, authCodeUrl, state, nonce, codeVerifier string,
err error, err error,
) { ) {
oauthProvider, ok := a.oauthAuthenticators[providerId] oauthProvider, ok := a.oauthAuthenticators[providerId]
if !ok { if !ok {
return "", "", "", fmt.Errorf("missing oauth provider %s", providerId) return "", "", "", "", fmt.Errorf("missing oauth provider %s", providerId)
} }
// Prepare authentication flow, set state cookies // Prepare authentication flow, set state cookies
state, err = a.randString(16) state, err = a.randString(16)
if err != nil { if err != nil {
return "", "", "", fmt.Errorf("failed to generate state: %w", err) return "", "", "", "", fmt.Errorf("failed to generate state: %w", err)
} }
// Generate PKCE code verifier and challenge if enabled. Otherwise, options will be empty.
authCodeOptions, codeVerifier := oauthProvider.PKCEAuthCodeOptions()
switch oauthProvider.GetType() { switch oauthProvider.GetType() {
case AuthenticatorTypeOAuth: case AuthenticatorTypeOAuth:
authCodeUrl = oauthProvider.AuthCodeURL(state) authCodeUrl = oauthProvider.AuthCodeURL(state, authCodeOptions...)
case AuthenticatorTypeOidc: case AuthenticatorTypeOidc:
nonce, err = a.randString(16) nonce, err = a.randString(16)
if err != nil { if err != nil {
return "", "", "", fmt.Errorf("failed to generate nonce: %w", err) return "", "", "", "", fmt.Errorf("failed to generate nonce: %w", err)
} }
authCodeUrl = oauthProvider.AuthCodeURL(state, oidc.Nonce(nonce)) authCodeOptions = append(authCodeOptions, oidc.Nonce(nonce))
authCodeUrl = oauthProvider.AuthCodeURL(state, authCodeOptions...)
} }
return return
@@ -497,31 +515,66 @@ func isDomainAllowed(email string, allowedDomains []string) bool {
return false return false
} }
// OauthLoginStep2 finishes the oauth authentication flow by exchanging the code for an access token and func isAnyAllowedUserGroup(userGroups, allowedUserGroups []string) bool {
// fetching the user information. if len(allowedUserGroups) == 0 {
func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error) { return true
oauthProvider, ok := a.oauthAuthenticators[providerId]
if !ok {
return nil, fmt.Errorf("missing oauth provider %s", providerId)
} }
oauth2Token, err := oauthProvider.Exchange(ctx, code) allowed := make(map[string]struct{}, len(allowedUserGroups))
if err != nil { for _, group := range allowedUserGroups {
return nil, fmt.Errorf("unable to exchange code: %w", err) trimmed := strings.TrimSpace(group)
if trimmed == "" {
continue
} }
allowed[trimmed] = struct{}{}
}
if len(allowed) == 0 {
return false
}
for _, group := range userGroups {
if _, ok := allowed[strings.TrimSpace(group)]; ok {
return true
}
}
return false
}
// OauthLoginStep2 finishes the oauth authentication flow by exchanging the code for an access token and
// fetching the user information.
func (a *Authenticator) OauthLoginStep2(
ctx context.Context,
providerId, nonce, code, codeVerifier string,
) (*domain.User, string, error) {
oauthProvider, ok := a.oauthAuthenticators[providerId]
if !ok {
return nil, "", fmt.Errorf("missing oauth provider %s", providerId)
}
oauth2Token, err := oauthProvider.Exchange(ctx, code, oauthProvider.PKCETokenOptions(codeVerifier)...)
if err != nil {
return nil, "", fmt.Errorf("unable to exchange code: %w", err)
}
idTokenHint, _ := oauth2Token.Extra("id_token").(string)
rawUserInfo, err := oauthProvider.GetUserInfo(ctx, oauth2Token, nonce) rawUserInfo, err := oauthProvider.GetUserInfo(ctx, oauth2Token, nonce)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to fetch user information: %w", err) return nil, "", fmt.Errorf("unable to fetch user information: %w", err)
} }
userInfo, err := oauthProvider.ParseUserInfo(rawUserInfo) userInfo, err := oauthProvider.ParseUserInfo(rawUserInfo)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to parse user information: %w", err) return nil, "", fmt.Errorf("unable to parse user information: %w", err)
} }
if !isDomainAllowed(userInfo.Email, oauthProvider.GetAllowedDomains()) { if !isDomainAllowed(userInfo.Email, oauthProvider.GetAllowedDomains()) {
return nil, fmt.Errorf("user %s is not in allowed domains", userInfo.Email) return nil, "", fmt.Errorf("user %s is not in allowed domains", userInfo.Email)
}
if !isAnyAllowedUserGroup(userInfo.UserGroups, oauthProvider.GetAllowedUserGroups()) {
return nil, "", fmt.Errorf("user %s is not in allowed user groups", userInfo.Identifier)
} }
ctx = domain.SetUserInfo(ctx, ctx = domain.SetUserInfo(ctx,
@@ -537,7 +590,7 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
Error: err.Error(), Error: err.Error(),
}, },
}) })
return nil, fmt.Errorf("unable to process user information: %w", err) return nil, "", fmt.Errorf("unable to process user information: %w", err)
} }
if user.IsLocked() || user.IsDisabled() { if user.IsLocked() || user.IsDisabled() {
@@ -549,7 +602,7 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
Error: "user is locked", Error: "user is locked",
}, },
}) })
return nil, errors.New("user is locked") return nil, "", errors.New("user is locked")
} }
a.bus.Publish(app.TopicAuthLogin, user.Identifier) a.bus.Publish(app.TopicAuthLogin, user.Identifier)
@@ -561,7 +614,16 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
}, },
}) })
return user, nil return user, idTokenHint, nil
}
func (a *Authenticator) OauthProviderLogoutUrl(providerId, idTokenHint, postLogoutRedirectUri string) (string, bool) {
oauthProvider, ok := a.oauthAuthenticators[providerId]
if !ok {
return "", false
}
return oauthProvider.GetLogoutUrl(idTokenHint, postLogoutRedirectUri)
} }
func (a *Authenticator) processUserInfo( func (a *Authenticator) processUserInfo(

View File

@@ -149,5 +149,9 @@ func (l LdapAuthenticator) ParseUserInfo(raw map[string]any) (*domain.Authentica
AdminInfoAvailable: adminInfoAvailable, AdminInfoAvailable: adminInfoAvailable,
} }
if err := userInfo.Sanitize("ldap", l.cfg.ProviderName); err != nil {
return nil, err
}
return userInfo, nil return userInfo, nil
} }

View File

@@ -0,0 +1,98 @@
package auth
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/testutil"
)
// makeLdapAuthenticator creates a minimal LdapAuthenticator for testing ParseUserInfo.
func makeLdapAuthenticator() *LdapAuthenticator {
return &LdapAuthenticator{
cfg: &config.LdapProvider{
ProviderName: "test-ldap",
FieldMap: config.LdapFields{
BaseFields: config.BaseFields{
UserIdentifier: "uid",
Email: "mail",
Firstname: "givenName",
Lastname: "sn",
Phone: "telephoneNumber",
Department: "department",
},
GroupMembership: "", // no group membership check
},
},
}
}
// makeRawLdapMap builds a minimal raw LDAP attribute map for ParseUserInfo.
func makeRawLdapMap(uid, mail, givenName, sn, phone, department string) map[string]any {
return map[string]any{
"uid": uid,
"mail": mail,
"givenName": givenName,
"sn": sn,
"telephoneNumber": phone,
"department": department,
}
}
// Test: firstname contains \x00 → output firstname has no null byte,
// one WARN log entry with field: "firstname".
func TestLdapParseUserInfo_NullByteInFirstname(t *testing.T) {
auth := makeLdapAuthenticator()
raw := makeRawLdapMap("alice", "alice@example.com", "Ali\x00ce", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
info, err := auth.ParseUserInfo(raw)
records := restore()
require.NoError(t, err)
assert.NotContains(t, info.Firstname, "\x00", "firstname should have null byte removed")
assert.Equal(t, "Alice", info.Firstname)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 1, warnCount, "expected exactly one WARN log entry")
rec, found := testutil.FindWarnWithField(records, "firstname")
assert.True(t, found, "expected WARN log entry with field=firstname")
if found {
assert.Equal(t, "WARN", rec["level"])
}
}
// Test: all fields clean → no WARN log entries emitted.
func TestLdapParseUserInfo_AllFieldsClean(t *testing.T) {
auth := makeLdapAuthenticator()
raw := makeRawLdapMap("alice", "alice@example.com", "Alice", "Smith", "+1 555-1234", "Engineering")
restore := testutil.CaptureWarnLogs(t)
info, err := auth.ParseUserInfo(raw)
records := restore()
require.NoError(t, err)
assert.Equal(t, domain.UserIdentifier("alice"), info.Identifier)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 0, warnCount, "expected no WARN log entries when all fields are clean")
}
// Test: identifier is "all" → returns ErrInvalidData.
func TestLdapParseUserInfo_IdentifierAll(t *testing.T) {
auth := makeLdapAuthenticator()
raw := makeRawLdapMap("all", "all@example.com", "Alice", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
_, err := auth.ParseUserInfo(raw)
_ = restore()
require.Error(t, err)
assert.True(t, errors.Is(err, domain.ErrInvalidData), "expected ErrInvalidData when identifier is 'all'")
}

View File

@@ -29,6 +29,9 @@ type PlainOauthAuthenticator struct {
userInfoLogging bool userInfoLogging bool
sensitiveInfoLogging bool sensitiveInfoLogging bool
allowedDomains []string allowedDomains []string
allowedUserGroups []string
usePKCE bool
pkceMethod string
} }
func newPlainOauthAuthenticator( func newPlainOauthAuthenticator(
@@ -60,6 +63,15 @@ func newPlainOauthAuthenticator(
provider.userInfoLogging = cfg.LogUserInfo provider.userInfoLogging = cfg.LogUserInfo
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains provider.allowedDomains = cfg.AllowedDomains
provider.allowedUserGroups = cfg.AllowedUserGroups
provider.usePKCE = cfg.UsePKCE == nil || *cfg.UsePKCE
provider.pkceMethod = cfg.PKCEMethod
if provider.pkceMethod == "" {
provider.pkceMethod = pkceMethodS256
}
if provider.usePKCE && provider.pkceMethod != pkceMethodS256 && provider.pkceMethod != pkceMethodPlain {
return nil, fmt.Errorf("unsupported PKCE method %q, allowed: S256, plain", provider.pkceMethod)
}
return provider, nil return provider, nil
} }
@@ -73,6 +85,40 @@ func (p PlainOauthAuthenticator) GetAllowedDomains() []string {
return p.allowedDomains return p.allowedDomains
} }
func (p PlainOauthAuthenticator) GetAllowedUserGroups() []string {
return p.allowedUserGroups
}
func (p PlainOauthAuthenticator) GetLogoutUrl(_, _ string) (string, bool) {
return "", false
}
// PKCEAuthCodeOptions returns PKCE options for the authorization request and the verifier for the token exchange.
func (p PlainOauthAuthenticator) PKCEAuthCodeOptions() ([]oauth2.AuthCodeOption, string) {
if !p.usePKCE {
return nil, ""
}
verifier := oauth2.GenerateVerifier()
if p.pkceMethod == pkceMethodPlain {
return []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("code_challenge", verifier),
oauth2.SetAuthURLParam("code_challenge_method", pkceMethodPlain),
}, verifier
}
return []oauth2.AuthCodeOption{oauth2.S256ChallengeOption(verifier)}, verifier
}
// PKCETokenOptions returns PKCE options for the token exchange.
func (p PlainOauthAuthenticator) PKCETokenOptions(verifier string) []oauth2.AuthCodeOption {
if !p.usePKCE || verifier == "" {
return nil
}
return []oauth2.AuthCodeOption{oauth2.VerifierOption(verifier)}
}
// RegistrationEnabled returns whether registration is enabled for the OAuth authenticator. // RegistrationEnabled returns whether registration is enabled for the OAuth authenticator.
func (p PlainOauthAuthenticator) RegistrationEnabled() bool { func (p PlainOauthAuthenticator) RegistrationEnabled() bool {
return p.registrationEnabled return p.registrationEnabled
@@ -145,5 +191,5 @@ func (p PlainOauthAuthenticator) GetUserInfo(
// ParseUserInfo parses the user information from the raw data. // ParseUserInfo parses the user information from the raw data.
func (p PlainOauthAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) { func (p PlainOauthAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) {
return parseOauthUserInfo(p.userInfoMapping, p.userAdminMapping, raw) return parseOauthUserInfo(p.userInfoMapping, p.userAdminMapping, raw, "oauth", p.name)
} }

View File

@@ -0,0 +1,61 @@
package auth
import (
"testing"
"golang.org/x/oauth2"
)
func TestPlainOauthAuthenticatorPKCES256Options(t *testing.T) {
authenticator := PlainOauthAuthenticator{usePKCE: true, pkceMethod: "S256"}
options, verifier := authenticator.PKCEAuthCodeOptions()
if verifier == "" {
t.Fatal("expected verifier")
}
values := authCodeValues(t, options)
if values.Get("code_challenge") == "" {
t.Fatal("expected code_challenge")
}
if values.Get("code_challenge_method") != "S256" {
t.Fatalf("expected S256 challenge method, got %q", values.Get("code_challenge_method"))
}
tokenOptions := authenticator.PKCETokenOptions(verifier)
if len(tokenOptions) != 1 {
t.Fatalf("expected one token option, got %d", len(tokenOptions))
}
}
func TestPlainOauthAuthenticatorPKCEPlainOptions(t *testing.T) {
authenticator := PlainOauthAuthenticator{usePKCE: true, pkceMethod: "plain"}
options, verifier := authenticator.PKCEAuthCodeOptions()
values := authCodeValues(t, options)
if values.Get("code_challenge") != verifier {
t.Fatalf("expected plain challenge %q, got %q", verifier, values.Get("code_challenge"))
}
if values.Get("code_challenge_method") != "plain" {
t.Fatalf("expected plain challenge method, got %q", values.Get("code_challenge_method"))
}
}
func TestPlainOauthAuthenticatorPKCEDisabled(t *testing.T) {
authenticator := PlainOauthAuthenticator{usePKCE: false, pkceMethod: "S256"}
options, verifier := authenticator.PKCEAuthCodeOptions()
if len(options) != 0 {
t.Fatalf("expected no auth code options, got %d", len(options))
}
if verifier != "" {
t.Fatalf("expected empty verifier, got %q", verifier)
}
tokenOptions := authenticator.PKCETokenOptions(oauth2.GenerateVerifier())
if len(tokenOptions) != 0 {
t.Fatalf("expected no token options, got %d", len(tokenOptions))
}
}

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/url"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@@ -26,6 +27,11 @@ type OidcAuthenticator struct {
userInfoLogging bool userInfoLogging bool
sensitiveInfoLogging bool sensitiveInfoLogging bool
allowedDomains []string allowedDomains []string
allowedUserGroups []string
endSessionEndpoint string
logoutIdpSession bool
usePKCE bool
pkceMethod string
} }
func newOidcAuthenticator( func newOidcAuthenticator(
@@ -61,6 +67,25 @@ func newOidcAuthenticator(
provider.userInfoLogging = cfg.LogUserInfo provider.userInfoLogging = cfg.LogUserInfo
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains provider.allowedDomains = cfg.AllowedDomains
provider.allowedUserGroups = cfg.AllowedUserGroups
provider.logoutIdpSession = cfg.LogoutIdpSession == nil || *cfg.LogoutIdpSession
provider.usePKCE = cfg.UsePKCE == nil || *cfg.UsePKCE
provider.pkceMethod = cfg.PKCEMethod
if provider.pkceMethod == "" {
provider.pkceMethod = pkceMethodS256
}
if provider.usePKCE && provider.pkceMethod != pkceMethodS256 && provider.pkceMethod != pkceMethodPlain {
return nil, fmt.Errorf("unsupported PKCE method %q, allowed: S256, plain", provider.pkceMethod)
}
var providerMetadata struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
if err = provider.provider.Claims(&providerMetadata); err != nil {
slog.Debug("OIDC: failed to parse provider metadata", "provider", cfg.ProviderName, "error", err)
} else {
provider.endSessionEndpoint = providerMetadata.EndSessionEndpoint
}
return provider, nil return provider, nil
} }
@@ -74,6 +99,64 @@ func (o OidcAuthenticator) GetAllowedDomains() []string {
return o.allowedDomains return o.allowedDomains
} }
func (o OidcAuthenticator) GetAllowedUserGroups() []string {
return o.allowedUserGroups
}
func (o OidcAuthenticator) GetLogoutUrl(idTokenHint, postLogoutRedirectUri string) (string, bool) {
if !o.logoutIdpSession {
return "", false
}
if o.endSessionEndpoint == "" {
slog.Debug("OIDC logout URL generation disabled: provider has no end_session_endpoint", "provider", o.name)
return "", false
}
logoutUrl, err := url.Parse(o.endSessionEndpoint)
if err != nil {
slog.Debug("OIDC logout URL generation failed, unable to parse end_session_endpoint url",
"provider", o.name, "error", err)
return "", false
}
params := logoutUrl.Query()
if idTokenHint != "" {
params.Set("id_token_hint", idTokenHint)
}
if postLogoutRedirectUri != "" {
params.Set("post_logout_redirect_uri", postLogoutRedirectUri)
}
logoutUrl.RawQuery = params.Encode()
return logoutUrl.String(), true
}
// PKCEAuthCodeOptions returns PKCE options for the authorization request and the verifier for the token exchange.
func (o OidcAuthenticator) PKCEAuthCodeOptions() ([]oauth2.AuthCodeOption, string) {
if !o.usePKCE {
return nil, ""
}
verifier := oauth2.GenerateVerifier()
if o.pkceMethod == pkceMethodPlain {
return []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("code_challenge", verifier),
oauth2.SetAuthURLParam("code_challenge_method", pkceMethodPlain),
}, verifier
}
return []oauth2.AuthCodeOption{oauth2.S256ChallengeOption(verifier)}, verifier
}
// PKCETokenOptions returns PKCE options for the token exchange.
func (o OidcAuthenticator) PKCETokenOptions(verifier string) []oauth2.AuthCodeOption {
if !o.usePKCE || verifier == "" {
return nil
}
return []oauth2.AuthCodeOption{oauth2.VerifierOption(verifier)}
}
// RegistrationEnabled returns whether registration is enabled for this authenticator. // RegistrationEnabled returns whether registration is enabled for this authenticator.
func (o OidcAuthenticator) RegistrationEnabled() bool { func (o OidcAuthenticator) RegistrationEnabled() bool {
return o.registrationEnabled return o.registrationEnabled
@@ -97,7 +180,7 @@ func (o OidcAuthenticator) Exchange(ctx context.Context, code string, opts ...oa
return o.cfg.Exchange(ctx, code, opts...) return o.cfg.Exchange(ctx, code, opts...)
} }
// GetUserInfo retrieves the user info from the token. // GetUserInfo retrieves the user info from the token and the userinfo endpoint.
func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token, nonce string) ( func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token, nonce string) (
map[string]any, map[string]any,
error, error,
@@ -135,6 +218,41 @@ func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token,
return nil, fmt.Errorf("failed to parse extra claims: %w", err) return nil, fmt.Errorf("failed to parse extra claims: %w", err)
} }
// Fetch additional user information from the userinfo endpoint
userInfo, err := o.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
if err != nil {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: failed to fetch user info from endpoint", "provider", o.name, "error", err)
}
// Don't fail the entire flow if userinfo endpoint is unavailable;
// ID token claims may be sufficient
slog.Debug("OIDC: proceeding with ID token claims only", "provider", o.name)
} else {
// Parse claims from userinfo endpoint response
var userInfoFields map[string]any
if err = userInfo.Claims(&userInfoFields); err != nil {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: failed to parse userinfo claims", "provider", o.name, "error", err)
}
// Don't fail if we can't parse userinfo; continue with ID token claims
slog.Debug("OIDC: proceeding with ID token claims only", "provider", o.name)
} else {
// Merge userinfo fields into tokenFields, preferring ID token claims
for key, value := range userInfoFields {
if _, exists := tokenFields[key]; !exists {
tokenFields[key] = value
}
}
if o.userInfoLogging {
contents, _ := json.Marshal(userInfoFields)
slog.Debug("OIDC: user info from endpoint",
"source", o.name,
"info", string(contents))
}
}
}
if o.userInfoLogging { if o.userInfoLogging {
contents, _ := json.Marshal(tokenFields) contents, _ := json.Marshal(tokenFields)
slog.Debug("OIDC: user info debug", slog.Debug("OIDC: user info debug",
@@ -147,5 +265,5 @@ func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token,
// ParseUserInfo parses the user info. // ParseUserInfo parses the user info.
func (o OidcAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) { func (o OidcAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) {
return parseOauthUserInfo(o.userInfoMapping, o.userAdminMapping, raw) return parseOauthUserInfo(o.userInfoMapping, o.userAdminMapping, raw, "oidc", o.name)
} }

View File

@@ -0,0 +1,79 @@
package auth
import (
"net/url"
"testing"
"golang.org/x/oauth2"
)
func authCodeValues(t *testing.T, options []oauth2.AuthCodeOption) url.Values {
t.Helper()
config := oauth2.Config{
ClientID: "client-id",
Endpoint: oauth2.Endpoint{AuthURL: "https://example.com/auth"},
RedirectURL: "https://wg.example.com/callback",
}
authCodeURL, err := url.Parse(config.AuthCodeURL("state", options...))
if err != nil {
t.Fatalf("failed to parse auth code URL: %v", err)
}
return authCodeURL.Query()
}
func TestOidcAuthenticatorPKCES256Options(t *testing.T) {
authenticator := OidcAuthenticator{usePKCE: true, pkceMethod: "S256"}
options, verifier := authenticator.PKCEAuthCodeOptions()
if verifier == "" {
t.Fatal("expected verifier")
}
values := authCodeValues(t, options)
if values.Get("code_challenge") == "" {
t.Fatal("expected code_challenge")
}
if values.Get("code_challenge_method") != "S256" {
t.Fatalf("expected S256 challenge method, got %q", values.Get("code_challenge_method"))
}
tokenOptions := authenticator.PKCETokenOptions(verifier)
if len(tokenOptions) != 1 {
t.Fatalf("expected one token option, got %d", len(tokenOptions))
}
}
func TestOidcAuthenticatorPKCEPlainOptions(t *testing.T) {
authenticator := OidcAuthenticator{usePKCE: true, pkceMethod: "plain"}
options, verifier := authenticator.PKCEAuthCodeOptions()
values := authCodeValues(t, options)
if values.Get("code_challenge") != verifier {
t.Fatalf("expected plain challenge %q, got %q", verifier, values.Get("code_challenge"))
}
if values.Get("code_challenge_method") != "plain" {
t.Fatalf("expected plain challenge method, got %q", values.Get("code_challenge_method"))
}
}
func TestOidcAuthenticatorPKCEDisabled(t *testing.T) {
authenticator := OidcAuthenticator{usePKCE: false, pkceMethod: "S256"}
options, verifier := authenticator.PKCEAuthCodeOptions()
if len(options) != 0 {
t.Fatalf("expected no auth code options, got %d", len(options))
}
if verifier != "" {
t.Fatalf("expected empty verifier, got %q", verifier)
}
tokenOptions := authenticator.PKCETokenOptions(oauth2.GenerateVerifier())
if len(tokenOptions) != 0 {
t.Fatalf("expected no token options, got %d", len(tokenOptions))
}
}

View File

@@ -13,9 +13,12 @@ func parseOauthUserInfo(
mapping config.OauthFields, mapping config.OauthFields,
adminMapping *config.OauthAdminMapping, adminMapping *config.OauthAdminMapping,
raw map[string]any, raw map[string]any,
providerType string,
providerName string,
) (*domain.AuthenticatorUserInfo, error) { ) (*domain.AuthenticatorUserInfo, error) {
var isAdmin bool var isAdmin bool
var adminInfoAvailable bool var adminInfoAvailable bool
userGroups := internal.MapDefaultStringSlice(raw, mapping.UserGroups, nil)
// first try to match the is_admin field against the given regex // first try to match the is_admin field against the given regex
if mapping.IsAdmin != "" { if mapping.IsAdmin != "" {
@@ -26,22 +29,10 @@ 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 {
if re.MatchString(strings.TrimSpace(group)) {
isAdmin = true
break
}
}
}
userInfo := &domain.AuthenticatorUserInfo{ userInfo := &domain.AuthenticatorUserInfo{
Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, mapping.UserIdentifier, "")), Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, mapping.UserIdentifier, "")),
Email: internal.MapDefaultString(raw, mapping.Email, ""), Email: internal.MapDefaultString(raw, mapping.Email, ""),
UserGroups: userGroups,
Firstname: internal.MapDefaultString(raw, mapping.Firstname, ""), Firstname: internal.MapDefaultString(raw, mapping.Firstname, ""),
Lastname: internal.MapDefaultString(raw, mapping.Lastname, ""), Lastname: internal.MapDefaultString(raw, mapping.Lastname, ""),
Phone: internal.MapDefaultString(raw, mapping.Phone, ""), Phone: internal.MapDefaultString(raw, mapping.Phone, ""),
@@ -50,6 +41,24 @@ func parseOauthUserInfo(
AdminInfoAvailable: adminInfoAvailable, AdminInfoAvailable: adminInfoAvailable,
} }
if err := userInfo.Sanitize(providerType, providerName); err != nil {
return nil, err
}
// check admin group match after sanitization
if !isAdmin && mapping.UserGroups != "" && adminMapping.AdminGroupRegex != "" {
adminInfoAvailable = true
re := adminMapping.GetAdminGroupRegex()
for _, group := range userInfo.UserGroups {
if re.MatchString(group) {
isAdmin = true
break
}
}
userInfo.IsAdmin = isAdmin
userInfo.AdminInfoAvailable = adminInfoAvailable
}
return userInfo, nil return userInfo, nil
} }

View File

@@ -0,0 +1,148 @@
package auth
import (
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/testutil"
)
// makeOauthFieldMapping returns a minimal OauthFields mapping for testing.
func makeOauthFieldMapping() config.OauthFields {
return config.OauthFields{
BaseFields: config.BaseFields{
UserIdentifier: "sub",
Email: "email",
Firstname: "given_name",
Lastname: "family_name",
Phone: "phone",
Department: "department",
},
}
}
// makeOauthRaw builds a minimal raw OAuth user info map.
func makeOauthRaw(sub, email, givenName, familyName, phone, department string) map[string]any {
return map[string]any{
"sub": sub,
"email": email,
"given_name": givenName,
"family_name": familyName,
"phone": phone,
"department": department,
}
}
// Test: email containing \r\n → output email is "",
// one WARN log entry with field: "email" and cleared indication.
func TestParseOauthUserInfo_CRLFInEmail(t *testing.T) {
mapping := makeOauthFieldMapping()
adminMapping := &config.OauthAdminMapping{}
raw := makeOauthRaw("user123", "user\r\n@example.com", "Alice", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
info, err := parseOauthUserInfo(mapping, adminMapping, raw, "oauth", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, "", info.Email, "email should be cleared when it contains CR/LF")
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 1, warnCount, "expected exactly one WARN log entry")
rec, found := testutil.FindWarnWithField(records, "email")
assert.True(t, found, "expected WARN log entry with field=email")
if found {
msg, _ := rec["msg"].(string)
assert.Contains(t, msg, "cleared", "expected 'cleared' in log message when email is cleared")
}
}
// Test: two fields modified (email cleared, firstname truncated) →
// two separate WARN log entries.
func TestParseOauthUserInfo_TwoFieldsModified(t *testing.T) {
mapping := makeOauthFieldMapping()
adminMapping := &config.OauthAdminMapping{}
longFirstname := strings.Repeat("A", 200)
raw := makeOauthRaw("user123", "bad\r\nemail@example.com", longFirstname, "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
info, err := parseOauthUserInfo(mapping, adminMapping, raw, "oauth", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, "", info.Email, "email should be cleared")
assert.Equal(t, 128, len([]rune(info.Firstname)), "firstname should be truncated to 128 runes")
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 2, warnCount, "expected exactly two WARN log entries (one per modified field)")
_, emailFound := testutil.FindWarnWithField(records, "email")
assert.True(t, emailFound, "expected WARN log entry with field=email")
_, firstnameFound := testutil.FindWarnWithField(records, "firstname")
assert.True(t, firstnameFound, "expected WARN log entry with field=firstname")
}
// Test: identifier "all" → returns ErrInvalidData.
func TestParseOauthUserInfo_IdentifierAll(t *testing.T) {
mapping := makeOauthFieldMapping()
adminMapping := &config.OauthAdminMapping{}
raw := makeOauthRaw("all", "all@example.com", "Alice", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
_, err := parseOauthUserInfo(mapping, adminMapping, raw, "oauth", "test-provider")
_ = restore()
require.Error(t, err)
assert.True(t, errors.Is(err, domain.ErrInvalidData), "expected ErrInvalidData when identifier is 'all'")
}
func TestParseOauthUserInfo_DropsModifiedGroupBeforeAdminMatch(t *testing.T) {
mapping := makeOauthFieldMapping()
mapping.UserGroups = "groups"
adminMapping := &config.OauthAdminMapping{
AdminGroupRegex: "^wgportal-admins$",
}
raw := makeOauthRaw("user123", "user@example.com", "Alice", "Smith", "", "")
raw["groups"] = []any{"wgportal-\u200badmins"}
restore := testutil.CaptureWarnLogs(t)
info, err := parseOauthUserInfo(mapping, adminMapping, raw, "oidc", "test-provider")
records := restore()
require.NoError(t, err)
require.NotNil(t, info)
assert.False(t, info.IsAdmin, "sanitization must not repair a modified group into an admin match")
assert.Empty(t, info.UserGroups)
rec, found := testutil.FindWarnWithField(records, "user_group")
assert.True(t, found, "expected WARN log entry with field=user_group")
if found {
assert.Equal(t, "oidc", rec["provider_type"])
}
}
func TestParseOauthUserInfo_AllowsWhitespaceOnlyGroupTrim(t *testing.T) {
mapping := makeOauthFieldMapping()
mapping.UserGroups = "groups"
adminMapping := &config.OauthAdminMapping{
AdminGroupRegex: "^wgportal-admins$",
}
raw := makeOauthRaw("user123", "user@example.com", "Alice", "Smith", "", "")
raw["groups"] = []any{" wgportal-admins "}
info, err := parseOauthUserInfo(mapping, adminMapping, raw, "oidc", "test-provider")
require.NoError(t, err)
require.NotNil(t, info)
assert.True(t, info.IsAdmin)
assert.Equal(t, []string{"wgportal-admins"}, info.UserGroups)
}

View File

@@ -43,7 +43,7 @@ func Test_parseOauthUserInfo_no_admin(t *testing.T) {
}) })
adminMapping := &config.OauthAdminMapping{} adminMapping := &config.OauthAdminMapping{}
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo) info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo, "oauth", "test-provider")
assert.NoError(t, err) assert.NoError(t, err)
assert.False(t, info.IsAdmin) assert.False(t, info.IsAdmin)
assert.Equal(t, info.Firstname, "Test User") assert.Equal(t, info.Firstname, "Test User")
@@ -90,12 +90,13 @@ func Test_parseOauthUserInfo_admin_group(t *testing.T) {
AdminGroupRegex: "^wgportal-admins@mydomain.net$", AdminGroupRegex: "^wgportal-admins@mydomain.net$",
} }
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo) info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo, "oauth", "test-provider")
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, info.IsAdmin) assert.True(t, info.IsAdmin)
assert.Equal(t, info.Firstname, "Test User") assert.Equal(t, info.Firstname, "Test User")
assert.Equal(t, info.Lastname, "") assert.Equal(t, info.Lastname, "")
assert.Equal(t, info.Email, "test@mydomain.net") assert.Equal(t, info.Email, "test@mydomain.net")
assert.Equal(t, info.UserGroups, []string{"abuse@mydomain.net", "postmaster@mydomain.net", "wgportal-admins@mydomain.net"})
} }
func Test_parseOauthUserInfo_admin_value(t *testing.T) { func Test_parseOauthUserInfo_admin_value(t *testing.T) {
@@ -131,7 +132,7 @@ func Test_parseOauthUserInfo_admin_value(t *testing.T) {
}) })
adminMapping := &config.OauthAdminMapping{} // test with default regex adminMapping := &config.OauthAdminMapping{} // test with default regex
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo) info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo, "oauth", "test-provider")
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, info.IsAdmin) assert.True(t, info.IsAdmin)
assert.Equal(t, info.Firstname, "Test User") assert.Equal(t, info.Firstname, "Test User")
@@ -174,7 +175,7 @@ func Test_parseOauthUserInfo_admin_value_custom(t *testing.T) {
AdminValueRegex: "^1$", AdminValueRegex: "^1$",
} }
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo) info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo, "oauth", "test-provider")
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, info.IsAdmin) assert.True(t, info.IsAdmin)
assert.Equal(t, info.Firstname, "Test User") assert.Equal(t, info.Firstname, "Test User")

View File

@@ -0,0 +1,90 @@
package auth
import (
"bytes"
"encoding/json"
"log/slog"
"testing"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/testutil"
)
// captureWarnLogsInline redirects the default slog logger to a buffer, calls fn,
// restores the original logger, and returns the captured log records.
func captureWarnLogsInline(fn func()) []map[string]any {
original := slog.Default()
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})
slog.SetDefault(slog.New(handler))
fn()
slog.SetDefault(original)
var records []map[string]any
decoder := json.NewDecoder(&buf)
for decoder.More() {
var rec map[string]any
if err := decoder.Decode(&rec); err == nil {
records = append(records, rec)
}
}
return records
}
// Property 7: Sanitization change logging completeness
func TestPropertySanitizationChangeLoggingCompleteness(t *testing.T) {
mapping := makeOauthFieldMapping()
adminMapping := &config.OauthAdminMapping{}
rapid.Check(t, func(t *rapid.T) {
sub := rapid.StringMatching(`[a-zA-Z0-9_@.-]{1,50}`).Draw(t, "sub")
email := rapid.String().Draw(t, "email")
firstname := rapid.String().Draw(t, "firstname")
lastname := rapid.String().Draw(t, "lastname")
phone := rapid.String().Draw(t, "phone")
department := rapid.String().Draw(t, "department")
if sub == "" {
sub = "testuser"
}
raw := makeOauthRaw(sub, email, firstname, lastname, phone, department)
// Count how many fields will actually change after sanitization
expectedChanges := 0
if domain.SanitizeIdentifier(sub, 256) != sub {
expectedChanges++
}
if domain.SanitizeEmail(email, 254) != email {
expectedChanges++
}
if domain.SanitizeString(firstname, 128) != firstname {
expectedChanges++
}
if domain.SanitizeString(lastname, 128) != lastname {
expectedChanges++
}
if domain.SanitizePhone(phone, 50) != phone {
expectedChanges++
}
if domain.SanitizeString(department, 128) != department {
expectedChanges++
}
var records []map[string]any
records = captureWarnLogsInline(func() {
_, _ = parseOauthUserInfo(mapping, adminMapping, raw, "oauth", "test-provider")
})
actualWarnCount := testutil.CountWarnEntries(records)
require.Equal(t, expectedChanges, actualWarnCount,
"number of WARN log entries (%d) must equal number of fields changed by sanitization (%d)",
actualWarnCount, expectedChanges)
})
}

View File

@@ -28,7 +28,7 @@ func convertRawLdapUser(
uid := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, "")) uid := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, ""))
return &domain.User{ user := &domain.User{
BaseModel: domain.BaseModel{ BaseModel: domain.BaseModel{
CreatedBy: domain.CtxSystemLdapSyncer, CreatedBy: domain.CtxSystemLdapSyncer,
UpdatedBy: domain.CtxSystemLdapSyncer, UpdatedBy: domain.CtxSystemLdapSyncer,
@@ -49,10 +49,16 @@ func convertRawLdapUser(
Lastname: internal.MapDefaultString(rawUser, fields.Lastname, ""), Lastname: internal.MapDefaultString(rawUser, fields.Lastname, ""),
Phone: internal.MapDefaultString(rawUser, fields.Phone, ""), Phone: internal.MapDefaultString(rawUser, fields.Phone, ""),
Department: internal.MapDefaultString(rawUser, fields.Department, ""), Department: internal.MapDefaultString(rawUser, fields.Department, ""),
Notes: "", }
Password: "",
Disabled: nil, if err := user.SanitizeExternalData("ldap", providerName); err != nil {
}, nil return nil, err
}
// Update authentication identifier after sanitization
user.Authentications[0].UserIdentifier = user.Identifier
return user, nil
} }
func userChangedInLdap(dbUser, ldapUser *domain.User) bool { func userChangedInLdap(dbUser, ldapUser *domain.User) bool {

View File

@@ -0,0 +1,136 @@
package users
import (
"errors"
"testing"
"github.com/go-ldap/ldap/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/testutil"
)
// makeTestLdapFields returns a minimal LdapFields config for testing.
func makeTestLdapFields() *config.LdapFields {
return &config.LdapFields{
BaseFields: config.BaseFields{
UserIdentifier: "uid",
Email: "mail",
Firstname: "givenName",
Lastname: "sn",
Phone: "telephoneNumber",
Department: "department",
},
GroupMembership: "memberOf",
}
}
// makeTestAdminGroupDN returns a parsed DN for testing (a non-matching group).
func makeTestAdminGroupDN(t *testing.T) *ldap.DN {
t.Helper()
dn, err := ldap.ParseDN("cn=admins,dc=example,dc=com")
require.NoError(t, err)
return dn
}
// makeRawLdapUser builds a raw LDAP user map for convertRawLdapUser.
func makeRawLdapUser(uid, mail, givenName, sn, phone, department string) map[string]any {
return map[string]any{
"uid": uid,
"mail": mail,
"givenName": givenName,
"sn": sn,
"telephoneNumber": phone,
"department": department,
"memberOf": [][]byte{}, // no group memberships
}
}
// Test: identifier "all" → returns ErrInvalidData,
// one WARN log entry with field: "identifier" and cleared indication.
func TestConvertRawLdapUser_IdentifierAll(t *testing.T) {
fields := makeTestLdapFields()
adminGroupDN := makeTestAdminGroupDN(t)
raw := makeRawLdapUser("all", "all@example.com", "Alice", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
user, err := convertRawLdapUser("test-ldap", raw, fields, adminGroupDN)
records := restore()
require.Error(t, err)
assert.True(t, errors.Is(err, domain.ErrInvalidData), "expected ErrInvalidData when identifier is 'all'")
assert.Nil(t, user)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 1, warnCount, "expected exactly one WARN log entry")
rec, found := testutil.FindWarnWithField(records, "identifier")
assert.True(t, found, "expected WARN log entry with field=identifier")
if found {
msg, _ := rec["msg"].(string)
assert.Contains(t, msg, "cleared", "expected 'cleared' in log message when identifier is cleared")
}
}
// Test: firstname contains \x00 → output firstname has null byte removed,
// one WARN log entry with field: "firstname".
func TestConvertRawLdapUser_NullByteInFirstname(t *testing.T) {
fields := makeTestLdapFields()
adminGroupDN := makeTestAdminGroupDN(t)
raw := makeRawLdapUser("alice", "alice@example.com", "Ali\x00ce", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
user, err := convertRawLdapUser("test-ldap", raw, fields, adminGroupDN)
records := restore()
require.NoError(t, err)
require.NotNil(t, user)
assert.NotContains(t, user.Firstname, "\x00", "firstname should have null byte removed")
assert.Equal(t, "Alice", user.Firstname)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 1, warnCount, "expected exactly one WARN log entry")
rec, found := testutil.FindWarnWithField(records, "firstname")
assert.True(t, found, "expected WARN log entry with field=firstname")
if found {
assert.Equal(t, "WARN", rec["level"])
}
}
// Test: all fields clean → no WARN log entries emitted.
func TestConvertRawLdapUser_AllFieldsClean(t *testing.T) {
fields := makeTestLdapFields()
adminGroupDN := makeTestAdminGroupDN(t)
raw := makeRawLdapUser("alice", "alice@example.com", "Alice", "Smith", "+1 555-1234", "Engineering")
restore := testutil.CaptureWarnLogs(t)
user, err := convertRawLdapUser("test-ldap", raw, fields, adminGroupDN)
records := restore()
require.NoError(t, err)
require.NotNil(t, user)
assert.Equal(t, domain.UserIdentifier("alice"), user.Identifier)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 0, warnCount, "expected no WARN log entries when all fields are clean")
}
func TestLdapUserIdentifier_NormalizesSyncComparisons(t *testing.T) {
raw := map[string]any{"uid": " alice\x00 "}
got := ldapUserIdentifier(raw, "uid")
assert.Equal(t, domain.UserIdentifier("alice"), got)
}
func TestLdapUserIdentifier_RejectsReservedIdentifier(t *testing.T) {
raw := map[string]any{"uid": " all "}
got := ldapUserIdentifier(raw, "uid")
assert.Empty(t, got)
}

View File

@@ -109,6 +109,11 @@ func (m Manager) updateLdapUsers(
for _, rawUser := range rawUsers { for _, rawUser := range rawUsers {
user, err := convertRawLdapUser(provider.ProviderName, rawUser, fields, adminGroupDN) user, err := convertRawLdapUser(provider.ProviderName, rawUser, fields, adminGroupDN)
if err != nil && !errors.Is(err, domain.ErrNotFound) { if err != nil && !errors.Is(err, domain.ErrNotFound) {
if errors.Is(err, domain.ErrInvalidData) {
slog.Warn("skipping LDAP user with invalid data after sanitization",
"raw-dn", rawUser["dn"], "error", err)
continue
}
return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err) return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err)
} }
@@ -212,7 +217,7 @@ func (m Manager) disableMissingLdapUsers(
existsInLDAP := false existsInLDAP := false
for _, rawUser := range rawUsers { for _, rawUser := range rawUsers {
userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, "")) userId := ldapUserIdentifier(rawUser, fields.UserIdentifier)
if user.Identifier == userId { if user.Identifier == userId {
existsInLDAP = true existsInLDAP = true
break break
@@ -270,7 +275,7 @@ func (m Manager) updateInterfaceLdapFilters(
matchedUserIds := make([]domain.UserIdentifier, 0, len(rawUsers)) matchedUserIds := make([]domain.UserIdentifier, 0, len(rawUsers))
for _, rawUser := range rawUsers { for _, rawUser := range rawUsers {
userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, provider.FieldMap.UserIdentifier, "")) userId := ldapUserIdentifier(rawUser, provider.FieldMap.UserIdentifier)
if userId != "" { if userId != "" {
matchedUserIds = append(matchedUserIds, userId) matchedUserIds = append(matchedUserIds, userId)
} }
@@ -299,3 +304,12 @@ func (m Manager) updateInterfaceLdapFilters(
return nil return nil
} }
func ldapUserIdentifier(rawUser map[string]any, field string) domain.UserIdentifier {
identifier := internal.MapDefaultString(rawUser, field, "")
identifier = domain.SanitizeIdentifier(identifier, 256)
if identifier == "" {
return ""
}
return domain.UserIdentifier(identifier)
}

View File

@@ -365,19 +365,7 @@ func (m Manager) validateCreation(ctx context.Context, new *domain.User) error {
return fmt.Errorf("invalid user identifier: %w", domain.ErrInvalidData) return fmt.Errorf("invalid user identifier: %w", domain.ErrInvalidData)
} }
if new.Identifier == "all" { // the 'all' user identifier collides with the rest api routes if domain.IsReservedUserIdentifier(new.Identifier) {
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}
if new.Identifier == "new" { // the 'new' user identifier collides with the rest api routes
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}
if new.Identifier == "id" { // the 'id' user identifier collides with the rest api routes
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}
if new.Identifier == domain.CtxSystemAdminId || new.Identifier == domain.CtxUnknownUserId {
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData) return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
} }

View File

@@ -36,6 +36,7 @@ type InterfaceAndPeerDatabaseRepo interface {
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error)
GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr) (map[domain.Cidr][]domain.Cidr, error) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr) (map[domain.Cidr][]domain.Cidr, error)
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
GetAllUsers(ctx context.Context) ([]domain.User, error)
} }
type WgQuickController interface { type WgQuickController interface {
@@ -60,6 +61,7 @@ type Manager struct {
wg *ControllerManager wg *ControllerManager
userLockMap *sync.Map userLockMap *sync.Map
interfaceLockMap *sync.Map
} }
func NewWireGuardManager( func NewWireGuardManager(
@@ -74,6 +76,7 @@ func NewWireGuardManager(
wg: wg, wg: wg,
db: db, db: db,
userLockMap: &sync.Map{}, userLockMap: &sync.Map{},
interfaceLockMap: &sync.Map{},
} }
m.connectToMessageBus() m.connectToMessageBus()
@@ -93,10 +96,11 @@ func (m Manager) connectToMessageBus() {
_ = m.bus.Subscribe(app.TopicUserDisabled, m.handleUserDisabledEvent) _ = m.bus.Subscribe(app.TopicUserDisabled, m.handleUserDisabledEvent)
_ = m.bus.Subscribe(app.TopicUserEnabled, m.handleUserEnabledEvent) _ = m.bus.Subscribe(app.TopicUserEnabled, m.handleUserEnabledEvent)
_ = m.bus.Subscribe(app.TopicUserDeleted, m.handleUserDeletedEvent) _ = m.bus.Subscribe(app.TopicUserDeleted, m.handleUserDeletedEvent)
_ = m.bus.Subscribe(app.TopicInterfaceCreated, m.handleInterfaceCreatedEvent)
} }
func (m Manager) handleUserCreationEvent(user domain.User) { func (m Manager) handleUserCreationEvent(user domain.User) {
if !m.cfg.Core.CreateDefaultPeerOnCreation { if !m.cfg.Core.CreateDefaultPeerOnUserCreation {
return return
} }
@@ -117,7 +121,7 @@ func (m Manager) handleUserCreationEvent(user domain.User) {
} }
func (m Manager) handleUserLoginEvent(userId domain.UserIdentifier) { func (m Manager) handleUserLoginEvent(userId domain.UserIdentifier) {
if !m.cfg.Core.CreateDefaultPeer { if !m.cfg.Core.CreateDefaultPeerOnLogin {
return return
} }
@@ -269,6 +273,31 @@ func (m Manager) handleUserDeletedEvent(user domain.User) {
} }
} }
// handleInterfaceCreatedEvent creates default peers for all existing users when a new interface is created.
// This ensures users that already exist (e.g. imported via a prior LDAP sync that had no interface available)
// also receive a default peer for the newly created interface.
func (m Manager) handleInterfaceCreatedEvent(iface domain.Interface) {
if !m.cfg.Core.CreateDefaultPeerOnUserCreation {
return
}
_, loaded := m.interfaceLockMap.LoadOrStore(iface.Identifier, "create")
if loaded {
return // another goroutine is already handling this interface
}
defer m.interfaceLockMap.Delete(iface.Identifier)
slog.Debug("handling new interface event", "interface", iface.Identifier)
ctx := domain.SetUserInfo(context.Background(), domain.SystemAdminContextUserInfo())
err := m.CreateDefaultPeers(ctx, iface.Identifier)
if err != nil {
slog.Error("failed to create default peers on new interface",
"interface", iface.Identifier, "error", err)
}
}
func (m Manager) runExpiredPeersCheck(ctx context.Context) { func (m Manager) runExpiredPeersCheck(ctx context.Context) {
ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo()) ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo())

View File

@@ -387,7 +387,7 @@ func (m Manager) PrepareInterface(ctx context.Context) (*domain.Interface, error
SaveConfig: m.cfg.Advanced.ConfigStoragePath != "", SaveConfig: m.cfg.Advanced.ConfigStoragePath != "",
DisplayName: string(id), DisplayName: string(id),
Type: domain.InterfaceTypeServer, Type: domain.InterfaceTypeServer,
CreateDefaultPeer: m.cfg.Core.CreateDefaultPeer, CreateDefaultPeer: m.cfg.DefaultPeerCreationEnabled(),
DriverType: "", DriverType: "",
Disabled: nil, Disabled: nil,
DisabledReason: "", DisabledReason: "",
@@ -893,16 +893,7 @@ func (m Manager) importInterface(
} }
} }
// try to predict the interface type based on the number of peers iface.Type = inferImportedInterfaceType(iface, peers)
switch len(peers) {
case 0:
iface.Type = domain.InterfaceTypeAny // no peers means this is an unknown interface
case 1:
iface.Type = domain.InterfaceTypeClient // one peer means this is a client interface
default: // multiple peers means this is a server interface
iface.Type = domain.InterfaceTypeServer
}
existingInterface, err := m.db.GetInterface(ctx, iface.Identifier) existingInterface, err := m.db.GetInterface(ctx, iface.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) { if err != nil && !errors.Is(err, domain.ErrNotFound) {
@@ -930,6 +921,20 @@ func (m Manager) importInterface(
return nil return nil
} }
func inferImportedInterfaceType(iface *domain.Interface, peers []domain.PhysicalPeer) domain.InterfaceType {
switch len(peers) {
case 0:
return domain.InterfaceTypeAny // no peers means this is an unknown interface
case 1:
if iface.ListenPort > 0 {
return domain.InterfaceTypeServer // a listening interface with one peer is commonly a site-to-site server
}
return domain.InterfaceTypeClient
default: // multiple peers means this is a server interface
return domain.InterfaceTypeServer
}
}
// extractPfsenseDefaultsFromPeers extracts common endpoint and DNS information from peers // extractPfsenseDefaultsFromPeers extracts common endpoint and DNS information from peers
// For server interfaces, peers typically have endpoints pointing to the server, so we use the most common one // For server interfaces, peers typically have endpoints pointing to the server, so we use the most common one
func extractPfsenseDefaultsFromPeers(peers []domain.PhysicalPeer, listenPort int) (endpoint, dns string) { func extractPfsenseDefaultsFromPeers(peers []domain.PhysicalPeer, listenPort int) (endpoint, dns string) {

View File

@@ -10,6 +10,49 @@ import (
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
func TestInferImportedInterfaceType(t *testing.T) {
tests := []struct {
name string
listenPort int
peerCount int
expected domain.InterfaceType
}{
{
name: "no peers stays unknown",
listenPort: 51820,
peerCount: 0,
expected: domain.InterfaceTypeAny,
},
{
name: "single peer with listen port is server",
listenPort: 51820,
peerCount: 1,
expected: domain.InterfaceTypeServer,
},
{
name: "single peer without listen port stays client",
listenPort: 0,
peerCount: 1,
expected: domain.InterfaceTypeClient,
},
{
name: "multiple peers is server",
listenPort: 0,
peerCount: 2,
expected: domain.InterfaceTypeServer,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
iface := &domain.Interface{ListenPort: tt.listenPort}
peers := make([]domain.PhysicalPeer, tt.peerCount)
assert.Equal(t, tt.expected, inferImportedInterfaceType(iface, peers))
})
}
}
func TestImportPeer_AddressMapping(t *testing.T) { func TestImportPeer_AddressMapping(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -94,13 +137,6 @@ func TestImportPeer_AddressMapping(t *testing.T) {
} }
} }
func (f *mockDB) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
return &domain.User{
Identifier: id,
IsAdmin: false,
}, nil
}
func TestInterface_IsUserAllowed(t *testing.T) { func TestInterface_IsUserAllowed(t *testing.T) {
cfg := &config.Config{ cfg := &config.Config{
Auth: config.Auth{ Auth: config.Auth{

View File

@@ -15,6 +15,10 @@ import (
// CreateDefaultPeer creates a default peer for the given user on all server interfaces. // CreateDefaultPeer creates a default peer for the given user on all server interfaces.
func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdentifier) error { func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdentifier) error {
if !m.cfg.DefaultPeerCreationEnabled() {
return nil
}
if err := domain.ValidateAdminAccessRights(ctx); err != nil { if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return err return err
} }
@@ -24,40 +28,22 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti
return fmt.Errorf("failed to fetch all interfaces: %w", err) return fmt.Errorf("failed to fetch all interfaces: %w", err)
} }
userPeers, err := m.db.GetUserPeers(context.Background(), userId) user, err := m.db.GetUser(ctx, userId)
if err != nil { if err != nil {
return fmt.Errorf("failed to retrieve existing peers prior to default peer creation: %w", err) return fmt.Errorf("failed to fetch user: %w", err)
} }
var newPeers []domain.Peer var newPeers []domain.Peer
for _, iface := range existingInterfaces { for _, iface := range existingInterfaces {
if iface.Type != domain.InterfaceTypeServer { peer, err := m.prepareDefaultPeer(ctx, &iface, user)
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
})
if peerAlreadyCreated {
continue // skip creation if a peer already exists for this interface
}
peer, err := m.PreparePeer(ctx, iface.Identifier)
if err != nil { if err != nil {
return fmt.Errorf("failed to create default peer for interface %s: %w", iface.Identifier, err) return fmt.Errorf("failed to prepare default peer: %w", err)
} }
peer.UserIdentifier = userId if peer != nil {
peer.Notes = fmt.Sprintf("Default peer created for user %s", userId)
peer.AutomaticallyCreated = true
peer.GenerateDisplayName("Default")
newPeers = append(newPeers, *peer) newPeers = append(newPeers, *peer)
} }
}
for i, peer := range newPeers { for i, peer := range newPeers {
_, err := m.CreatePeer(ctx, &newPeers[i]) _, err := m.CreatePeer(ctx, &newPeers[i])
@@ -67,9 +53,61 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti
} }
} }
slog.InfoContext(ctx, "created default peers for user", slog.InfoContext(ctx, "created default peers for user", "user", userId, "count", len(newPeers))
"user", userId,
"count", len(newPeers)) return nil
}
// CreateDefaultPeers creates default peers for all existing users on the given interface.
func (m Manager) CreateDefaultPeers(ctx context.Context, interfaceId domain.InterfaceIdentifier) error {
if !m.cfg.DefaultPeerCreationEnabled() {
return nil
}
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return err
}
iface, err := m.db.GetInterface(ctx, interfaceId)
if err != nil {
return fmt.Errorf("failed to fetch interface %s: %w", interfaceId, err)
}
if !iface.CreateDefaultPeers() {
return nil
}
users, err := m.db.GetAllUsers(ctx)
if err != nil {
return fmt.Errorf("failed to fetch all users: %w", err)
}
var errs error
var peerCount int
for _, user := range users {
peer, err := m.prepareDefaultPeer(ctx, iface, &user)
if err != nil {
errs = errors.Join(errs, fmt.Errorf("failed to prepare default peer for user %s: %w",
user.Identifier, err))
continue
}
if peer == nil {
continue
}
_, err = m.CreatePeer(ctx, peer)
if err != nil {
errs = errors.Join(errs, fmt.Errorf("failed to create default peer for user %s: %w",
user.Identifier, err))
continue
}
peerCount++
}
if errs != nil {
return fmt.Errorf("failed to create default peers for interface %s: %w", interfaceId, errs)
}
slog.InfoContext(ctx, "created default peers for interface", "interface", interfaceId, "count", peerCount)
return nil return nil
} }
@@ -639,4 +677,39 @@ func (m Manager) checkInterfaceAccess(ctx context.Context, id domain.InterfaceId
return nil return nil
} }
func (m Manager) prepareDefaultPeer(ctx context.Context, iface *domain.Interface, user *domain.User) (
*domain.Peer,
error,
) {
if !iface.CreateDefaultPeers() || !user.CreateDefaultPeers() {
return nil, nil
}
userPeers, err := m.db.GetUserPeers(ctx, user.Identifier)
if err != nil {
return nil, fmt.Errorf("failed to retrieve existing peers prior to default peer creation: %w", err)
}
peerAlreadyCreated := slices.ContainsFunc(userPeers, func(peer domain.Peer) bool {
// Ignore the AutomaticallyCreated flag on the peer.
// If a user already has a peer for a given interface, no default peer should be created.
return peer.InterfaceIdentifier == iface.Identifier
})
if peerAlreadyCreated {
return nil, nil // skip creation if a peer already exists for this interface
}
peer, err := m.PreparePeer(ctx, iface.Identifier)
if err != nil {
return nil, fmt.Errorf("failed to create default peer for interface %s: %w", iface.Identifier, err)
}
peer.UserIdentifier = user.Identifier
peer.Notes = fmt.Sprintf("Default peer created for user %s", user.Identifier)
peer.AutomaticallyCreated = true
peer.GenerateDisplayName("Default")
return peer, nil
}
// endregion helper-functions // endregion helper-functions

View File

@@ -61,6 +61,7 @@ type mockDB struct {
savedPeers map[domain.PeerIdentifier]*domain.Peer savedPeers map[domain.PeerIdentifier]*domain.Peer
iface *domain.Interface iface *domain.Interface
interfaces []domain.Interface interfaces []domain.Interface
users []domain.User
} }
func (f *mockDB) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) { func (f *mockDB) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
@@ -141,6 +142,15 @@ func (f *mockDB) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr)
) { ) {
return map[domain.Cidr][]domain.Cidr{}, nil return map[domain.Cidr][]domain.Cidr{}, nil
} }
func (f *mockDB) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
return &domain.User{
Identifier: id,
IsAdmin: false,
}, nil
}
func (f *mockDB) GetAllUsers(ctx context.Context) ([]domain.User, error) {
return f.users, nil
}
// --- Test --- // --- Test ---
@@ -205,7 +215,7 @@ func TestCreatePeer_SetsIdentifier_FromPublicKey(t *testing.T) {
func TestCreateDefaultPeer_RespectsInterfaceFlag(t *testing.T) { func TestCreateDefaultPeer_RespectsInterfaceFlag(t *testing.T) {
// Arrange // Arrange
cfg := &config.Config{} cfg := &config.Config{}
cfg.Core.CreateDefaultPeer = true cfg.Core.CreateDefaultPeerOnLogin = true
bus := &mockBus{} bus := &mockBus{}
ctrlMgr := &ControllerManager{ ctrlMgr := &ControllerManager{

View File

@@ -258,6 +258,10 @@ type OpenIDConnectProvider struct {
// AllowedDomains defines the list of allowed domains // AllowedDomains defines the list of allowed domains
AllowedDomains []string `yaml:"allowed_domains"` AllowedDomains []string `yaml:"allowed_domains"`
// AllowedUserGroups defines the list of allowed user groups.
// If not empty, at least one group from the user's group claim must match.
AllowedUserGroups []string `yaml:"allowed_user_groups"`
// FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields // FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields
FieldMap OauthFields `yaml:"field_map"` FieldMap OauthFields `yaml:"field_map"`
@@ -274,6 +278,19 @@ type OpenIDConnectProvider struct {
// If LogSensitiveInfo is set to true, sensitive information retrieved from the OIDC provider will be logged in trace level. // If LogSensitiveInfo is set to true, sensitive information retrieved from the OIDC provider will be logged in trace level.
// This also includes OAuth tokens! Keep this disabled in production! // This also includes OAuth tokens! Keep this disabled in production!
LogSensitiveInfo bool `yaml:"log_sensitive_info"` LogSensitiveInfo bool `yaml:"log_sensitive_info"`
// UsePKCE controls whether Proof Key for Code Exchange is used during the authorization code flow.
// If unset, PKCE is enabled by default.
UsePKCE *bool `yaml:"use_pkce"`
// PKCEMethod controls which PKCE challenge method is used. Supported values are "S256" and "plain".
// If empty, "S256" is used.
PKCEMethod string `yaml:"pkce_method"`
// LogoutIdpSession controls whether the user's session at the OIDC provider is terminated on logout.
// If set to true (default), the user will be redirected to the IdP's end_session_endpoint after local logout.
// If set to false, only the local wg-portal session is invalidated.
LogoutIdpSession *bool `yaml:"logout_idp_session"`
} }
// OAuthProvider contains the configuration for the OAuth provider. // OAuthProvider contains the configuration for the OAuth provider.
@@ -303,6 +320,10 @@ type OAuthProvider struct {
// AllowedDomains defines the list of allowed domains // AllowedDomains defines the list of allowed domains
AllowedDomains []string `yaml:"allowed_domains"` AllowedDomains []string `yaml:"allowed_domains"`
// AllowedUserGroups defines the list of allowed user groups.
// If not empty, at least one group from the user's group claim must match.
AllowedUserGroups []string `yaml:"allowed_user_groups"`
// FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields // FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields
FieldMap OauthFields `yaml:"field_map"` FieldMap OauthFields `yaml:"field_map"`
@@ -319,6 +340,14 @@ type OAuthProvider struct {
// If LogSensitiveInfo is set to true, sensitive information retrieved from the OAuth provider will be logged in trace level. // If LogSensitiveInfo is set to true, sensitive information retrieved from the OAuth provider will be logged in trace level.
// This also includes OAuth tokens! Keep this disabled in production! // This also includes OAuth tokens! Keep this disabled in production!
LogSensitiveInfo bool `yaml:"log_sensitive_info"` LogSensitiveInfo bool `yaml:"log_sensitive_info"`
// UsePKCE controls whether Proof Key for Code Exchange is used during the authorization code flow.
// If unset, PKCE is enabled by default.
UsePKCE *bool `yaml:"use_pkce"`
// PKCEMethod controls which PKCE challenge method is used. Supported values are "S256" and "plain".
// If empty, "S256" is used.
PKCEMethod string `yaml:"pkce_method"`
} }
// WebauthnConfig contains the configuration for the WebAuthn authenticator. // WebauthnConfig contains the configuration for the WebAuthn authenticator.

View File

@@ -22,8 +22,11 @@ type Config struct {
AdminApiToken string `yaml:"admin_api_token"` // if set, the API access is enabled automatically AdminApiToken string `yaml:"admin_api_token"` // if set, the API access is enabled automatically
EditableKeys bool `yaml:"editable_keys"` EditableKeys bool `yaml:"editable_keys"`
CreateDefaultPeer bool `yaml:"create_default_peer"` CreateDefaultPeer bool `yaml:"create_default_peer"` // DEPRECATED: in favor of CreateDefaultPeerOnLogin
CreateDefaultPeerOnCreation bool `yaml:"create_default_peer_on_creation"` CreateDefaultPeerOnCreation bool `yaml:"create_default_peer_on_creation"` // DEPRECATED: in favor of CreateDefaultPeerOnUserCreation
CreateDefaultPeerOnLogin bool `yaml:"create_default_peer_on_login"`
CreateDefaultPeerOnUserCreation bool `yaml:"create_default_peer_on_user_creation"`
CreateDefaultPeerOnInterfaceCreation bool `yaml:"create_default_peer_on_interface_creation"`
ReEnablePeerAfterUserEnable bool `yaml:"re_enable_peer_after_user_enable"` ReEnablePeerAfterUserEnable bool `yaml:"re_enable_peer_after_user_enable"`
DeletePeerAfterUserDeleted bool `yaml:"delete_peer_after_user_deleted"` DeletePeerAfterUserDeleted bool `yaml:"delete_peer_after_user_deleted"`
SelfProvisioningAllowed bool `yaml:"self_provisioning_allowed"` SelfProvisioningAllowed bool `yaml:"self_provisioning_allowed"`
@@ -78,7 +81,7 @@ func (c *Config) LogStartupValues() {
slog.Debug("Config Features", slog.Debug("Config Features",
"editableKeys", c.Core.EditableKeys, "editableKeys", c.Core.EditableKeys,
"createDefaultPeerOnCreation", c.Core.CreateDefaultPeerOnCreation, "createDefaultPeerOnCreation", c.Core.CreateDefaultPeerOnUserCreation,
"reEnablePeerAfterUserEnable", c.Core.ReEnablePeerAfterUserEnable, "reEnablePeerAfterUserEnable", c.Core.ReEnablePeerAfterUserEnable,
"deletePeerAfterUserDeleted", c.Core.DeletePeerAfterUserDeleted, "deletePeerAfterUserDeleted", c.Core.DeletePeerAfterUserDeleted,
"selfProvisioningAllowed", c.Core.SelfProvisioningAllowed, "selfProvisioningAllowed", c.Core.SelfProvisioningAllowed,
@@ -112,6 +115,13 @@ func (c *Config) LogStartupValues() {
} }
// DefaultPeerCreationEnabled returns true if at least one default peer generation mechanism is enabled.
func (c *Config) DefaultPeerCreationEnabled() bool {
return c.Core.CreateDefaultPeerOnLogin ||
c.Core.CreateDefaultPeerOnInterfaceCreation ||
c.Core.CreateDefaultPeerOnUserCreation
}
// defaultConfig returns the default configuration // defaultConfig returns the default configuration
func defaultConfig() *Config { func defaultConfig() *Config {
cfg := &Config{} cfg := &Config{}
@@ -122,8 +132,13 @@ func defaultConfig() *Config {
cfg.Core.AdminApiToken = getEnvStr("WG_PORTAL_CORE_ADMIN_API_TOKEN", "") // by default, the API access is disabled cfg.Core.AdminApiToken = getEnvStr("WG_PORTAL_CORE_ADMIN_API_TOKEN", "") // by default, the API access is disabled
cfg.Core.ImportExisting = getEnvBool("WG_PORTAL_CORE_IMPORT_EXISTING", true) cfg.Core.ImportExisting = getEnvBool("WG_PORTAL_CORE_IMPORT_EXISTING", true)
cfg.Core.RestoreState = getEnvBool("WG_PORTAL_CORE_RESTORE_STATE", true) cfg.Core.RestoreState = getEnvBool("WG_PORTAL_CORE_RESTORE_STATE", true)
cfg.Core.CreateDefaultPeer = getEnvBool("WG_PORTAL_CORE_CREATE_DEFAULT_PEER", false) cfg.Core.CreateDefaultPeer = getEnvBool("WG_PORTAL_CORE_CREATE_DEFAULT_PEER", false) // deprecated
cfg.Core.CreateDefaultPeerOnCreation = getEnvBool("WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION", false) cfg.Core.CreateDefaultPeerOnCreation = getEnvBool("WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION",
false) // deprecated
cfg.Core.CreateDefaultPeerOnLogin = getEnvBool("WG_PORTAL_CORE_CREATE_DEFAULT_PEER", false)
cfg.Core.CreateDefaultPeerOnUserCreation = getEnvBool("WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_USER_CREATION", false)
cfg.Core.CreateDefaultPeerOnInterfaceCreation = getEnvBool("WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_INTERFACE_CREATION",
false)
cfg.Core.EditableKeys = getEnvBool("WG_PORTAL_CORE_EDITABLE_KEYS", true) cfg.Core.EditableKeys = getEnvBool("WG_PORTAL_CORE_EDITABLE_KEYS", true)
cfg.Core.SelfProvisioningAllowed = getEnvBool("WG_PORTAL_CORE_SELF_PROVISIONING_ALLOWED", false) cfg.Core.SelfProvisioningAllowed = getEnvBool("WG_PORTAL_CORE_SELF_PROVISIONING_ALLOWED", false)
cfg.Core.ReEnablePeerAfterUserEnable = getEnvBool("WG_PORTAL_CORE_RE_ENABLE_PEER_AFTER_USER_ENABLE", true) cfg.Core.ReEnablePeerAfterUserEnable = getEnvBool("WG_PORTAL_CORE_RE_ENABLE_PEER_AFTER_USER_ENABLE", true)
@@ -246,6 +261,8 @@ func GetConfig() (*Config, error) {
} }
} }
handleDeprecatedConfigValues(cfg)
return cfg, nil return cfg, nil
} }
@@ -339,3 +356,18 @@ func getEnvDuration(name string, fallback time.Duration) time.Duration {
return d return d
} }
func handleDeprecatedConfigValues(cfg *Config) {
// deprecated, will be removed in 2.4
if cfg.Core.CreateDefaultPeer {
slog.Warn("DEPRECATION WARNING: deprecated core config option: create_default_peer (WG_PORTAL_CORE_CREATE_DEFAULT_PEER)")
cfg.Core.CreateDefaultPeerOnLogin = true
}
// deprecated, will be removed in 2.4
if cfg.Core.CreateDefaultPeerOnCreation {
slog.Warn("DEPRECATION WARNING: deprecated core config option: create_default_peer_on_creation (WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION)")
cfg.Core.CreateDefaultPeerOnUserCreation = true
cfg.Core.CreateDefaultPeerOnInterfaceCreation = true
}
}

View File

@@ -1,5 +1,10 @@
package domain package domain
import (
"fmt"
"strings"
)
type LoginProvider string type LoginProvider string
type LoginProviderInfo struct { type LoginProviderInfo struct {
@@ -12,6 +17,7 @@ type LoginProviderInfo struct {
type AuthenticatorUserInfo struct { type AuthenticatorUserInfo struct {
Identifier UserIdentifier Identifier UserIdentifier
Email string Email string
UserGroups []string
Firstname string Firstname string
Lastname string Lastname string
Phone string Phone string
@@ -19,3 +25,52 @@ type AuthenticatorUserInfo struct {
IsAdmin bool IsAdmin bool
AdminInfoAvailable bool // true if the IsAdmin flag is valid AdminInfoAvailable bool // true if the IsAdmin flag is valid
} }
// Sanitize sanitizes all external identity provider fields in place.
// Returns ErrInvalidData if the identifier becomes empty after sanitization.
func (u *AuthenticatorUserInfo) Sanitize(providerType, providerName string) error {
identifier := string(u.Identifier)
LogSanitizeChange(providerType, providerName, "identifier", identifier,
func() string { return SanitizeIdentifier(identifier, 256) }, &identifier)
u.Identifier = UserIdentifier(identifier)
email := u.Email
LogSanitizeChange(providerType, providerName, "email", email,
func() string { return SanitizeEmail(email, 254) }, &u.Email)
LogSanitizeChange(providerType, providerName, "firstname", u.Firstname,
func() string { return SanitizeString(u.Firstname, 128) }, &u.Firstname)
LogSanitizeChange(providerType, providerName, "lastname", u.Lastname,
func() string { return SanitizeString(u.Lastname, 128) }, &u.Lastname)
LogSanitizeChange(providerType, providerName, "phone", u.Phone,
func() string { return SanitizePhone(u.Phone, 50) }, &u.Phone)
LogSanitizeChange(providerType, providerName, "department", u.Department,
func() string { return SanitizeString(u.Department, 128) }, &u.Department)
u.UserGroups = sanitizeGroups(providerType, providerName, u.UserGroups)
if u.Identifier == "" {
return fmt.Errorf("empty user identifier: %w", ErrInvalidData)
}
return nil
}
// sanitizeGroups sanitizes group names, dropping any that were modified by sanitization.
func sanitizeGroups(providerType, providerName string, rawGroups []string) []string {
if len(rawGroups) == 0 {
return rawGroups
}
groups := make([]string, 0, len(rawGroups))
for _, rawGroup := range rawGroups {
sanitized := rawGroup
LogSanitizeChange(providerType, providerName, "user_group", rawGroup,
func() string { return SanitizeString(rawGroup, 256) }, &sanitized)
if sanitized == "" || sanitized != strings.TrimSpace(rawGroup) {
continue
}
groups = append(groups, sanitized)
}
return groups
}

View File

@@ -0,0 +1,103 @@
package domain
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/testutil"
)
func TestAuthenticatorUserInfo_Sanitize_NullByteInFirstname(t *testing.T) {
info := &AuthenticatorUserInfo{
Identifier: "alice",
Email: "alice@example.com",
Firstname: "Ali\x00ce",
Lastname: "Smith",
}
restore := testutil.CaptureWarnLogs(t)
err := info.Sanitize("ldap", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, "Alice", info.Firstname)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 1, warnCount)
_, found := testutil.FindWarnWithField(records, "firstname")
assert.True(t, found)
}
func TestAuthenticatorUserInfo_Sanitize_AllFieldsClean(t *testing.T) {
info := &AuthenticatorUserInfo{
Identifier: "alice",
Email: "alice@example.com",
Firstname: "Alice",
Lastname: "Smith",
Phone: "+1 555-1234",
Department: "Engineering",
}
restore := testutil.CaptureWarnLogs(t)
err := info.Sanitize("ldap", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, UserIdentifier("alice"), info.Identifier)
assert.Equal(t, 0, testutil.CountWarnEntries(records))
}
func TestAuthenticatorUserInfo_Sanitize_IdentifierAll(t *testing.T) {
info := &AuthenticatorUserInfo{
Identifier: "all",
Email: "all@example.com",
Firstname: "Alice",
Lastname: "Smith",
}
err := info.Sanitize("ldap", "test-provider")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidData))
}
func TestAuthenticatorUserInfo_Sanitize_CRLFInEmail(t *testing.T) {
info := &AuthenticatorUserInfo{
Identifier: "user123",
Email: "user\r\n@example.com",
Firstname: "Alice",
Lastname: "Smith",
}
restore := testutil.CaptureWarnLogs(t)
err := info.Sanitize("oauth", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, "", info.Email)
_, found := testutil.FindWarnWithField(records, "email")
assert.True(t, found)
}
func TestAuthenticatorUserInfo_Sanitize_GroupsWithZeroWidthChars(t *testing.T) {
info := &AuthenticatorUserInfo{
Identifier: "user123",
Email: "user@example.com",
UserGroups: []string{"wgportal-\u200badmins"},
}
restore := testutil.CaptureWarnLogs(t)
err := info.Sanitize("oidc", "test-provider")
records := restore()
require.NoError(t, err)
assert.Empty(t, info.UserGroups)
_, found := testutil.FindWarnWithField(records, "user_group")
assert.True(t, found)
}

View File

@@ -240,6 +240,18 @@ func (i *Interface) GetRoutingTable() int {
} }
} }
// CreateDefaultPeers determines whether default peers should be created for this interface.
func (i *Interface) CreateDefaultPeers() bool {
if !i.CreateDefaultPeer {
return false // only create default peers if the interface flag is set
}
if i.Type != InterfaceTypeServer {
return false // only create default peers for server interfaces
}
return true
}
type PhysicalInterface struct { type PhysicalInterface struct {
Identifier InterfaceIdentifier // device name, for example: wg0 Identifier InterfaceIdentifier // device name, for example: wg0
KeyPair // private/public Key of the server interface KeyPair // private/public Key of the server interface

View File

@@ -139,3 +139,29 @@ func TestInterface_GetRoutingTableNonLocal(t *testing.T) {
iface.RoutingTable = "abc" iface.RoutingTable = "abc"
assert.Equal(t, 0, iface.GetRoutingTable()) assert.Equal(t, 0, iface.GetRoutingTable())
} }
func TestInterface_CreateDefaultPeers(t *testing.T) {
iface := &Interface{}
assert.False(t, iface.CreateDefaultPeers())
iface.CreateDefaultPeer = true
assert.False(t, iface.CreateDefaultPeers()) // still wrong type
iface2 := &Interface{Type: InterfaceTypeServer}
assert.False(t, iface2.CreateDefaultPeers()) // CreateDefaultPeer flag is false
iface2.CreateDefaultPeer = true
assert.True(t, iface2.CreateDefaultPeers())
iface3 := &Interface{Type: InterfaceTypeClient}
assert.False(t, iface3.CreateDefaultPeers())
iface3.CreateDefaultPeer = true
assert.False(t, iface3.CreateDefaultPeers())
iface4 := &Interface{Type: InterfaceTypeAny}
assert.False(t, iface4.CreateDefaultPeers())
iface4.CreateDefaultPeer = true
assert.False(t, iface4.CreateDefaultPeers())
}

151
internal/domain/sanitize.go Normal file
View File

@@ -0,0 +1,151 @@
package domain
import (
"log/slog"
"net/mail"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/text/unicode/norm"
)
// LogSanitizeChange applies sanitizeFn to raw, logs when the value changes, and writes
// the sanitized value to dest. Raw and sanitized values are intentionally omitted.
func LogSanitizeChange(
providerType string,
providerName string,
field string,
raw string,
sanitizeFn func() string,
dest *string,
) {
sanitized := sanitizeFn()
if sanitized != raw {
message := "sanitization modified field value from external provider"
if sanitized == "" {
message = "sanitization cleared field value from external provider"
}
slog.Warn(message,
"provider_type", SanitizeString(providerType, 64),
"provider", SanitizeString(providerName, 128),
"field", SanitizeString(field, 64),
)
}
*dest = sanitized
}
var reservedUserIdentifiers = map[string]struct{}{
"all": {},
"new": {},
"id": {},
CtxSystemAdminId: {},
CtxUnknownUserId: {},
CtxSystemLdapSyncer: {},
CtxSystemWgImporter: {},
CtxSystemV1Migrator: {},
CtxSystemDBMigrator: {},
}
// SanitizeString normalizes to NFC, trims leading and trailing whitespace, strips Unicode
// control and format characters, drops invalid UTF-8 bytes, and truncates the result to
// maxLen runes. If maxLen <= 0, returns "".
func SanitizeString(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
s = norm.NFC.String(strings.TrimSpace(s))
var b strings.Builder
b.Grow(len(s))
for len(s) > 0 {
r, size := utf8.DecodeRuneInString(s)
s = s[size:]
if r == utf8.RuneError && size == 1 {
continue
}
if !unicode.IsControl(r) && !unicode.Is(unicode.Cf, r) {
b.WriteRune(r)
}
}
s = b.String()
if utf8.RuneCountInString(s) > maxLen {
runes := []rune(s)
s = string(runes[:maxLen])
}
return strings.TrimSpace(s)
}
// SanitizeEmail applies SanitizeString first, then returns "" if the original s
// contains CR/LF or if the sanitized result is not a plain email address.
func SanitizeEmail(s string, maxLen int) string {
if strings.ContainsRune(s, '\r') || strings.ContainsRune(s, '\n') {
return ""
}
sanitized := SanitizeString(s, maxLen)
if sanitized == "" || strings.Count(sanitized, "@") != 1 {
return ""
}
addr, err := mail.ParseAddress(sanitized)
if err != nil || addr.Name != "" || addr.Address != sanitized {
return ""
}
return sanitized
}
// SanitizePhone applies SanitizeString first, then removes all characters not in the
// set [0-9+\-() .]. Returns "" if the result after filtering is empty.
func SanitizePhone(s string, maxLen int) string {
sanitized := SanitizeString(s, maxLen)
// Remove all characters not in [0-9+\-() .]
var b strings.Builder
b.Grow(len(sanitized))
for _, r := range sanitized {
if isAllowedPhoneRune(r) {
b.WriteRune(r)
}
}
result := strings.TrimSpace(b.String())
if result == "" {
return ""
}
return result
}
// isAllowedPhoneRune reports whether r is in the allowed phone character set [0-9+\-() .].
func isAllowedPhoneRune(r rune) bool {
switch {
case r >= '0' && r <= '9':
return true
case r == '+', r == '-', r == '(', r == ')', r == ' ', r == '.':
return true
default:
return false
}
}
// SanitizeIdentifier applies SanitizeString first, then returns "" if the result equals
// a reserved user identifier (case-sensitive, exact match).
func SanitizeIdentifier(s string, maxLen int) string {
sanitized := SanitizeString(s, maxLen)
if IsReservedUserIdentifier(UserIdentifier(sanitized)) {
return ""
}
return sanitized
}
func IsReservedUserIdentifier(identifier UserIdentifier) bool {
_, reserved := reservedUserIdentifiers[string(identifier)]
return reserved
}

View File

@@ -0,0 +1,503 @@
package domain
import (
"net/mail"
"strings"
"testing"
"unicode"
"unicode/utf8"
"pgregory.net/rapid"
)
func TestSanitizeString(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
want string
}{
{
name: "null byte removed",
input: "\x00",
maxLen: 64,
want: "",
},
{
name: "CR removed",
input: "\r",
maxLen: 64,
want: "",
},
{
name: "LF removed",
input: "\n",
maxLen: 64,
want: "",
},
{
name: "tab removed",
input: "\t",
maxLen: 64,
want: "",
},
{
name: "leading and trailing whitespace trimmed",
input: " hello ",
maxLen: 64,
want: "hello",
},
{
name: "multi-byte UTF-8 truncation at rune boundary",
input: "héllo",
maxLen: 3,
want: "hél", // 3 runes, not 3 bytes
},
{
name: "empty input",
input: "",
maxLen: 64,
want: "",
},
{
name: "maxLen zero returns empty",
input: "hello",
maxLen: 0,
want: "",
},
{
name: "string longer than maxLen truncated",
input: "abcdefgh",
maxLen: 4,
want: "abcd",
},
{
name: "mixed control chars and normal chars",
input: "hel\x00lo\r\nworld",
maxLen: 64,
want: "helloworld",
},
{
name: "only whitespace returns empty",
input: " ",
maxLen: 64,
want: "",
},
{
name: "string exactly at maxLen not truncated",
input: "abc",
maxLen: 3,
want: "abc",
},
{
name: "negative maxLen returns empty",
input: "hello",
maxLen: -1,
want: "",
},
{
name: "DEL control removed",
input: "hel\x7flo",
maxLen: 64,
want: "hello",
},
{
name: "zero-width format character removed",
input: "ali\u200bce",
maxLen: 64,
want: "alice",
},
{
name: "invalid UTF-8 byte removed",
input: "a\xffb",
maxLen: 64,
want: "ab",
},
{
name: "unicode normalized to NFC",
input: "e\u0301",
maxLen: 64,
want: "é",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SanitizeString(tc.input, tc.maxLen)
if got != tc.want {
t.Errorf("SanitizeString(%q, %d) = %q; want %q", tc.input, tc.maxLen, got, tc.want)
}
})
}
}
func TestSanitizeEmail(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
want string
}{
{
name: "valid email passes through unchanged",
input: "user@example.com",
maxLen: 254,
want: "user@example.com",
},
{
name: "CR in email returns empty",
input: "user\r@example.com",
maxLen: 254,
want: "",
},
{
name: "LF in email returns empty",
input: "user\n@example.com",
maxLen: 254,
want: "",
},
{
name: "missing @ returns empty",
input: "userexample.com",
maxLen: 254,
want: "",
},
{
name: "whitespace-only returns empty",
input: " ",
maxLen: 254,
want: "",
},
{
name: "email with leading/trailing whitespace trimmed and returned",
input: " user@example.com ",
maxLen: 254,
want: "user@example.com",
},
{
name: "empty input returns empty",
input: "",
maxLen: 254,
want: "",
},
{
name: "display-name address rejected",
input: "User <user@example.com>",
maxLen: 254,
want: "",
},
{
name: "multiple at signs rejected",
input: "user@@example.com",
maxLen: 254,
want: "",
},
{
name: "invalid address rejected",
input: "user@",
maxLen: 254,
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SanitizeEmail(tc.input, tc.maxLen)
if got != tc.want {
t.Errorf("SanitizeEmail(%q, %d) = %q; want %q", tc.input, tc.maxLen, got, tc.want)
}
})
}
}
func TestSanitizePhone(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
want string
}{
{
name: "valid phone passes through unchanged",
input: "+1 (555) 123-4567",
maxLen: 50,
want: "+1 (555) 123-4567",
},
{
name: "non-allowed chars stripped",
input: "abc+1def",
maxLen: 50,
want: "+1",
},
{
name: "all-stripped input returns empty",
input: "abc",
maxLen: 50,
want: "",
},
{
name: "mixed allowed and non-allowed chars",
input: "+49 (0) 123-456.789",
maxLen: 50,
want: "+49 (0) 123-456.789",
},
{
name: "empty input returns empty",
input: "",
maxLen: 50,
want: "",
},
{
name: "only digits passes through",
input: "1234567890",
maxLen: 50,
want: "1234567890",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SanitizePhone(tc.input, tc.maxLen)
if got != tc.want {
t.Errorf("SanitizePhone(%q, %d) = %q; want %q", tc.input, tc.maxLen, got, tc.want)
}
})
}
}
func TestSanitizeIdentifier(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
want string
}{
{
name: "reserved value all returns empty",
input: "all",
maxLen: 256,
want: "",
},
{
name: "all with surrounding whitespace returns empty",
input: " all ",
maxLen: 256,
want: "",
},
{
name: "reserved value new returns empty",
input: "new",
maxLen: 256,
want: "",
},
{
name: "reserved value id returns empty",
input: "id",
maxLen: 256,
want: "",
},
{
name: "system admin identifier returns empty",
input: string(CtxSystemAdminId),
maxLen: 256,
want: "",
},
{
name: "unknown user identifier returns empty",
input: string(CtxUnknownUserId),
maxLen: 256,
want: "",
},
{
name: "LDAP syncer identifier returns empty",
input: string(CtxSystemLdapSyncer),
maxLen: 256,
want: "",
},
{
name: "ALL uppercase passes through (case-sensitive)",
input: "ALL",
maxLen: 256,
want: "ALL",
},
{
name: "valid email identifier passes through",
input: "alice@example.com",
maxLen: 256,
want: "alice@example.com",
},
{
name: "normal identifier passes through",
input: "alice",
maxLen: 256,
want: "alice",
},
{
name: "empty input returns empty",
input: "",
maxLen: 256,
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SanitizeIdentifier(tc.input, tc.maxLen)
if got != tc.want {
t.Errorf("SanitizeIdentifier(%q, %d) = %q; want %q", tc.input, tc.maxLen, got, tc.want)
}
})
}
}
func TestSanitizeXSSPayload(t *testing.T) {
// XSS payload: null byte removed, angle brackets preserved
input := "<script>\x00alert(1)</script>"
want := "<script>alert(1)</script>"
got := SanitizeString(input, 256)
if got != want {
t.Errorf("SanitizeString(%q, 256) = %q; want %q", input, got, want)
}
}
// ---------------------------------------------------------------------------
// Property 1: SanitizeString output invariants
// ---------------------------------------------------------------------------
// Feature: external-identity-sanitization, Property 1: SanitizeString output is free of control characters and bounded in length
func TestPropertySanitizeStringOutputInvariants(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
s := rapid.String().Draw(t, "s")
maxLen := rapid.IntRange(0, 512).Draw(t, "maxLen")
result := SanitizeString(s, maxLen)
// No control or format runes in result
for _, r := range result {
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) {
t.Fatalf("result %q contains unsafe character %U", result, r)
}
}
if !utf8.ValidString(result) {
t.Fatalf("result %q is not valid UTF-8", result)
}
// No leading or trailing whitespace
if result != strings.TrimSpace(result) {
t.Fatalf("result %q has leading or trailing whitespace", result)
}
// Rune count <= maxLen
runeCount := utf8.RuneCountInString(result)
if runeCount > maxLen {
t.Fatalf("result %q has %d runes, exceeds maxLen %d", result, runeCount, maxLen)
}
})
}
// ---------------------------------------------------------------------------
// Property 2: SanitizeString is idempotent
// ---------------------------------------------------------------------------
// Feature: external-identity-sanitization, Property 2: SanitizeString is idempotent
func TestPropertySanitizeStringIdempotent(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
s := rapid.String().Draw(t, "s")
maxLen := rapid.IntRange(1, 512).Draw(t, "maxLen")
once := SanitizeString(s, maxLen)
twice := SanitizeString(once, maxLen)
if once != twice {
t.Fatalf("SanitizeString is not idempotent: once=%q, twice=%q (input=%q, maxLen=%d)",
once, twice, s, maxLen)
}
})
}
// ---------------------------------------------------------------------------
// Property 3: SanitizeEmail rejection rules
// ---------------------------------------------------------------------------
// Feature: external-identity-sanitization, Property 3: SanitizeEmail rejects strings without "@" or containing CR/LF
func TestPropertySanitizeEmailRejectionRules(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
s := rapid.String().Draw(t, "s")
maxLen := rapid.IntRange(1, 512).Draw(t, "maxLen")
result := SanitizeEmail(s, maxLen)
sanitized := SanitizeString(s, maxLen)
addr, parseErr := mail.ParseAddress(sanitized)
reject := strings.ContainsAny(s, "\r\n") ||
sanitized == "" ||
strings.Count(sanitized, "@") != 1 ||
parseErr != nil ||
addr.Name != "" ||
addr.Address != sanitized
if reject {
if result != "" {
t.Fatalf("SanitizeEmail(%q, %d) = %q; expected empty string (contains CR/LF or no @)",
s, maxLen, result)
}
}
})
}
// ---------------------------------------------------------------------------
// Property 4: SanitizePhone allowed character set
// ---------------------------------------------------------------------------
// isAllowedPhoneCharTest mirrors the internal isAllowedPhoneRune logic for test assertions.
func isAllowedPhoneCharTest(r rune) bool {
switch {
case r >= '0' && r <= '9':
return true
case r == '+', r == '-', r == '(', r == ')', r == ' ', r == '.':
return true
default:
return false
}
}
// Feature: external-identity-sanitization, Property 4: SanitizePhone output contains only allowed characters
func TestPropertySanitizePhoneAllowedChars(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
s := rapid.String().Draw(t, "s")
maxLen := rapid.IntRange(1, 512).Draw(t, "maxLen")
result := SanitizePhone(s, maxLen)
for _, r := range result {
if !isAllowedPhoneCharTest(r) {
t.Fatalf("SanitizePhone(%q, %d) = %q; contains disallowed rune %U (%c)",
s, maxLen, result, r, r)
}
}
})
}
// ---------------------------------------------------------------------------
// Property 5: SanitizeIdentifier rejects reserved identifiers
// ---------------------------------------------------------------------------
// Feature: external-identity-sanitization, Property 5: SanitizeIdentifier rejects reserved values
func TestPropertySanitizeIdentifierRejectsReservedValues(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
s := rapid.String().Draw(t, "s")
maxLen := rapid.IntRange(1, 512).Draw(t, "maxLen")
result := SanitizeIdentifier(s, maxLen)
sanitized := SanitizeString(s, maxLen)
_, reserved := reservedUserIdentifiers[sanitized]
if reserved {
if result != "" {
t.Fatalf("SanitizeIdentifier(%q, %d) = %q; expected empty string when sanitized is reserved",
s, maxLen, result)
}
} else {
if result != sanitized {
t.Fatalf("SanitizeIdentifier(%q, %d) = %q; expected %q (== SanitizeString result)",
s, maxLen, result, sanitized)
}
}
})
}

View File

@@ -270,6 +270,44 @@ func (u *User) DisplayName() string {
return displayName return displayName
} }
// CreateDefaultPeers determines whether default peers should be created for this user.
func (u *User) CreateDefaultPeers() bool {
if u.IsDisabled() {
return false
}
if u.IsLocked() {
return false
}
return true
}
// SanitizeExternalData sanitizes user profile fields received from an external identity provider.
// Returns ErrInvalidData if the identifier becomes empty after sanitization.
func (u *User) SanitizeExternalData(providerType, providerName string) error {
identifier := string(u.Identifier)
LogSanitizeChange(providerType, providerName, "identifier", identifier,
func() string { return SanitizeIdentifier(identifier, 256) }, &identifier)
u.Identifier = UserIdentifier(identifier)
LogSanitizeChange(providerType, providerName, "email", u.Email,
func() string { return SanitizeEmail(u.Email, 254) }, &u.Email)
LogSanitizeChange(providerType, providerName, "firstname", u.Firstname,
func() string { return SanitizeString(u.Firstname, 128) }, &u.Firstname)
LogSanitizeChange(providerType, providerName, "lastname", u.Lastname,
func() string { return SanitizeString(u.Lastname, 128) }, &u.Lastname)
LogSanitizeChange(providerType, providerName, "phone", u.Phone,
func() string { return SanitizePhone(u.Phone, 50) }, &u.Phone)
LogSanitizeChange(providerType, providerName, "department", u.Department,
func() string { return SanitizeString(u.Department, 128) }, &u.Department)
if u.Identifier == "" {
return fmt.Errorf("empty user identifier: %w", ErrInvalidData)
}
return nil
}
// region webauthn // region webauthn
func (u *User) WebAuthnID() []byte { func (u *User) WebAuthnID() []byte {

View File

@@ -0,0 +1,64 @@
package domain
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/testutil"
)
func TestUser_SanitizeExternalData_NullByteInFirstname(t *testing.T) {
u := &User{
Identifier: "alice",
Email: "alice@example.com",
Firstname: "Ali\x00ce",
Lastname: "Smith",
}
restore := testutil.CaptureWarnLogs(t)
err := u.SanitizeExternalData("ldap", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, "Alice", u.Firstname)
assert.Equal(t, 1, testutil.CountWarnEntries(records))
_, found := testutil.FindWarnWithField(records, "firstname")
assert.True(t, found)
}
func TestUser_SanitizeExternalData_IdentifierAll(t *testing.T) {
u := &User{
Identifier: "all",
Email: "all@example.com",
Firstname: "Alice",
Lastname: "Smith",
}
err := u.SanitizeExternalData("ldap", "test-provider")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidData))
}
func TestUser_SanitizeExternalData_AllFieldsClean(t *testing.T) {
u := &User{
Identifier: "alice",
Email: "alice@example.com",
Firstname: "Alice",
Lastname: "Smith",
Phone: "+1 555-1234",
Department: "Engineering",
}
restore := testutil.CaptureWarnLogs(t)
err := u.SanitizeExternalData("ldap", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, UserIdentifier("alice"), u.Identifier)
assert.Equal(t, 0, testutil.CountWarnEntries(records))
}

View File

@@ -145,3 +145,17 @@ func TestUser_HashPassword(t *testing.T) {
user.Password = "" user.Password = ""
assert.NoError(t, user.HashPassword()) assert.NoError(t, user.HashPassword())
} }
func TestUser_CreateDefaultPeers(t *testing.T) {
user := &User{}
assert.True(t, user.CreateDefaultPeers())
user2 := &User{Disabled: &time.Time{}}
assert.False(t, user2.CreateDefaultPeers())
user3 := &User{Locked: &time.Time{}}
assert.False(t, user3.CreateDefaultPeers())
user4 := &User{Disabled: &time.Time{}, Locked: &time.Time{}}
assert.False(t, user4.CreateDefaultPeers())
}

View File

@@ -57,6 +57,9 @@ type EmptyResponse struct{}
func (JsonObject GenericJsonObject) GetString(key string) string { func (JsonObject GenericJsonObject) GetString(key string) string {
if value, ok := JsonObject[key]; ok { if value, ok := JsonObject[key]; ok {
if value == nil {
return ""
}
if strValue, ok := value.(string); ok { if strValue, ok := value.(string); ok {
return strValue return strValue
} else { } else {

View File

@@ -193,6 +193,7 @@ func (p *PfsenseApiClient) preparePayloadRequest(
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal payload: %w", err) return nil, fmt.Errorf("failed to marshal payload: %w", err)
} }
p.debugLog("Prepared payload", "payload", string(payloadBytes))
req, err := http.NewRequestWithContext(ctx, method, fullUrl, bytes.NewReader(payloadBytes)) req, err := http.NewRequestWithContext(ctx, method, fullUrl, bytes.NewReader(payloadBytes))
if err != nil { if err != nil {
@@ -405,11 +406,12 @@ func (p *PfsenseApiClient) Update(
func (p *PfsenseApiClient) Delete( func (p *PfsenseApiClient) Delete(
ctx context.Context, ctx context.Context,
command string, command string,
opts *PfsenseRequestOptions,
) PfsenseApiResponse[EmptyResponse] { ) PfsenseApiResponse[EmptyResponse] {
apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout()) apiCtx, cancel := context.WithTimeout(ctx, p.cfg.GetApiTimeout())
defer cancel() defer cancel()
fullUrl := p.getFullPath(command) fullUrl := opts.GetPath(p.getFullPath(command))
req, err := p.prepareDeleteRequest(apiCtx, fullUrl) req, err := p.prepareDeleteRequest(apiCtx, fullUrl)
if err != nil { if err != nil {
@@ -425,4 +427,3 @@ func (p *PfsenseApiClient) Delete(
} }
// endregion API-client // endregion API-client

32
internal/sanitize/log.go Normal file
View File

@@ -0,0 +1,32 @@
package sanitize
import (
"log/slog"
"github.com/h44z/wg-portal/internal/domain"
)
// LogChange applies sanitizeFn to raw, logs when the value changes, and writes
// the sanitized value to dest. Raw and sanitized values are intentionally omitted.
func LogChange(
providerType string,
providerName string,
field string,
raw string,
sanitizeFn func() string,
dest *string,
) {
sanitized := sanitizeFn()
if sanitized != raw {
message := "sanitization modified field value from external provider"
if sanitized == "" {
message = "sanitization cleared field value from external provider"
}
slog.Warn(message,
"provider_type", domain.SanitizeString(providerType, 64),
"provider", domain.SanitizeString(providerName, 128),
"field", domain.SanitizeString(field, 64),
)
}
*dest = sanitized
}

View File

@@ -0,0 +1,50 @@
package testutil
import (
"bytes"
"encoding/json"
"log/slog"
"testing"
)
func CaptureWarnLogs(t *testing.T) (restore func() []map[string]any) {
t.Helper()
original := slog.Default()
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})
slog.SetDefault(slog.New(handler))
return func() []map[string]any {
slog.SetDefault(original)
var records []map[string]any
decoder := json.NewDecoder(&buf)
for decoder.More() {
var rec map[string]any
if err := decoder.Decode(&rec); err == nil {
records = append(records, rec)
}
}
return records
}
}
func CountWarnEntries(records []map[string]any) int {
count := 0
for _, r := range records {
if lvl, ok := r["level"].(string); ok && lvl == "WARN" {
count++
}
}
return count
}
func FindWarnWithField(records []map[string]any, fieldName string) (map[string]any, bool) {
for _, r := range records {
if lvl, ok := r["level"].(string); ok && lvl == "WARN" {
if f, ok := r["field"].(string); ok && f == fieldName {
return r, true
}
}
}
return nil, false
}