mirror of
https://github.com/h44z/wg-portal.git
synced 2026-06-06 13:16:18 +00:00
Merge branch 'master' into stable
This commit is contained in:
8
Makefile
8
Makefile
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 it’s next modified.
|
> Only new or updated records will be encrypted; existing data remains in plaintext until it’s 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.
|
||||||
|
|||||||
523
frontend/package-lock.json
generated
523
frontend/package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
121
frontend/src/components/Pagination.vue
Normal file
121
frontend/src/components/Pagination.vue
Normal 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()">«</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()">»</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.page-item.disabled .page-link {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
651
frontend/src/lang/translations/ja.json
Normal file
651
frontend/src/lang/translations/ja.json
Normal 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": "ピア表示名に追加されるプレフィックス。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -395,6 +395,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button-apply-defaults": "피어 기본값 적용",
|
"button-apply-defaults": "피어 기본값 적용",
|
||||||
|
"button-create-default-peers": "기본 피어 생성",
|
||||||
"confirm-delete": "인터페이스 '{id}'를 삭제하시겠습니까?"
|
"confirm-delete": "인터페이스 '{id}'를 삭제하시겠습니까?"
|
||||||
},
|
},
|
||||||
"peer-view": {
|
"peer-view": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -486,6 +486,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button-apply-defaults": "Применить настройки пира по умолчанию",
|
"button-apply-defaults": "Применить настройки пира по умолчанию",
|
||||||
|
"button-create-default-peers": "Создать пиров по умолчанию",
|
||||||
"confirm-delete": "Вы уверены, что хотите удалить интерфейс «{id}»?"
|
"confirm-delete": "Вы уверены, что хотите удалить интерфейс «{id}»?"
|
||||||
},
|
},
|
||||||
"peer-view": {
|
"peer-view": {
|
||||||
|
|||||||
@@ -377,6 +377,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button-apply-defaults": "Застосувати значення за замовчуванням для пірів",
|
"button-apply-defaults": "Застосувати значення за замовчуванням для пірів",
|
||||||
|
"button-create-default-peers": "Створити пірів за замовчуванням",
|
||||||
"confirm-delete": "Ви впевнені, що хочете видалити інтерфейс «{id}»?"
|
"confirm-delete": "Ви впевнені, що хочете видалити інтерфейс «{id}»?"
|
||||||
},
|
},
|
||||||
"peer-view": {
|
"peer-view": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -355,6 +355,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button-apply-defaults": "应用节点默认值",
|
"button-apply-defaults": "应用节点默认值",
|
||||||
|
"button-create-default-peers": "创建默认节点",
|
||||||
"confirm-delete": "确定要删除接口“{id}”吗?"
|
"confirm-delete": "确定要删除接口“{id}”吗?"
|
||||||
},
|
},
|
||||||
"peer-view": {
|
"peer-view": {
|
||||||
|
|||||||
@@ -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: '/',
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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!",
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">«</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">»</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>
|
||||||
|
|||||||
@@ -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">«</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">»</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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">«</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">»</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>
|
||||||
|
|||||||
@@ -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">«</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">»</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
74
go.mod
@@ -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
196
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
// Actual endpoint: POST /api/v2/vpn/wireguard/tunnel (singular)
|
|
||||||
createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/tunnel", lowlevel.GenericJsonObject{
|
|
||||||
"name": string(id),
|
|
||||||
})
|
|
||||||
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 (c *PfsenseController) updateInterface(ctx context.Context, pi *domain.PhysicalInterface) error {
|
type pfsenseWireGuardAddress struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Mask int `json:"mask"`
|
||||||
|
Descr string `json:"descr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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) 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,37 +834,22 @@ 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 {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &existingPeer, nil
|
return &existingPeer, nil
|
||||||
}
|
|
||||||
|
|
||||||
// create a new peer if it does not exist
|
|
||||||
// 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(
|
func (c *PfsenseController) createOrUpdatePeer(
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
249
internal/app/api/v0/handlers/endpoint_websocket_test.go
Normal file
249
internal/app/api/v0/handlers/endpoint_websocket_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
98
internal/app/auth/auth_ldap_sanitize_test.go
Normal file
98
internal/app/auth/auth_ldap_sanitize_test.go
Normal 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'")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
61
internal/app/auth/auth_oauth_test.go
Normal file
61
internal/app/auth/auth_oauth_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
79
internal/app/auth/auth_oidc_test.go
Normal file
79
internal/app/auth/auth_oidc_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
148
internal/app/auth/oauth_common_sanitize_test.go
Normal file
148
internal/app/auth/oauth_common_sanitize_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
90
internal/app/auth/sanitize_log_test.go
Normal file
90
internal/app/auth/sanitize_log_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
136
internal/app/users/ldap_helper_sanitize_test.go
Normal file
136
internal/app/users/ldap_helper_sanitize_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
103
internal/domain/auth_sanitize_test.go
Normal file
103
internal/domain/auth_sanitize_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
151
internal/domain/sanitize.go
Normal 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
|
||||||
|
}
|
||||||
503
internal/domain/sanitize_test.go
Normal file
503
internal/domain/sanitize_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
64
internal/domain/user_sanitize_test.go
Normal file
64
internal/domain/user_sanitize_test.go
Normal 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))
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
32
internal/sanitize/log.go
Normal 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
|
||||||
|
}
|
||||||
50
internal/testutil/testutil.go
Normal file
50
internal/testutil/testutil.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user