mirror of
https://github.com/h44z/wg-portal.git
synced 2026-02-23 10:56:22 +00:00
Compare commits
38 Commits
cleanup_ro
...
multi_auth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79eaedb9ca | ||
|
|
70832bfb52 | ||
|
|
5d58df8a19 | ||
|
|
2200509bc0 | ||
|
|
1b56acac87 | ||
|
|
015220dc7b | ||
|
|
4b49a55ea2 | ||
|
|
93db40c995 | ||
|
|
0a88fe745f | ||
|
|
8cc937b031 | ||
|
|
54ca1d8aed | ||
|
|
a318118ee6 | ||
|
|
a8b4b23742 | ||
|
|
a1fcce6fde | ||
|
|
364f7b3a5b | ||
|
|
907bb0599a | ||
|
|
d759fc7dc7 | ||
|
|
67192170fc | ||
|
|
8f25bef050 | ||
|
|
8bc4990441 | ||
|
|
80dc7f290a | ||
|
|
de91506bfa | ||
|
|
380d71ba07 | ||
|
|
3d4a190949 | ||
|
|
df450cf384 | ||
|
|
9fbebc82f6 | ||
|
|
7c557d3e66 | ||
|
|
bda99464f1 | ||
|
|
d66a4b71b8 | ||
|
|
da76327569 | ||
|
|
c154cb3977 | ||
|
|
7bca35728d | ||
|
|
3d923b328e | ||
|
|
139fb17f98 | ||
|
|
faf1d995a8 | ||
|
|
f53d0b3d7f | ||
|
|
cdf3a49801 | ||
|
|
298c9405f6 |
12
.github/workflows/chart.yml
vendored
12
.github/workflows/chart.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -35,16 +35,16 @@ jobs:
|
|||||||
# ct lint requires Python 3.x to run following packages:
|
# ct lint requires Python 3.x to run following packages:
|
||||||
# - yamale (https://github.com/23andMe/Yamale)
|
# - yamale (https://github.com/23andMe/Yamale)
|
||||||
# - yamllint (https://github.com/adrienverge/yamllint)
|
# - yamllint (https://github.com/adrienverge/yamllint)
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.x'
|
python-version: '3.x'
|
||||||
|
|
||||||
- uses: helm/chart-testing-action@v2
|
- uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
|
||||||
|
|
||||||
- name: Run chart-testing (lint)
|
- name: Run chart-testing (lint)
|
||||||
run: ct lint --config ct.yaml
|
run: ct lint --config ct.yaml
|
||||||
|
|
||||||
- uses: nolar/setup-k3d-k3s@v1
|
- uses: nolar/setup-k3d-k3s@293b8e5822a20bc0d5bcdd4826f1a665e72aba96 # v1.0.9
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -60,9 +60,9 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
22
.github/workflows/docker-publish.yml
vendored
22
.github/workflows/docker-publish.yml
vendored
@@ -18,13 +18,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Get Version
|
- name: Get Version
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -32,14 +32,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
wgportal/wg-portal
|
wgportal/wg-portal
|
||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
type=semver,pattern=v{{major}}
|
type=semver,pattern=v{{major}}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
@@ -80,7 +80,7 @@ jobs:
|
|||||||
BUILD_VERSION=${{ env.BUILD_VERSION }}
|
BUILD_VERSION=${{ env.BUILD_VERSION }}
|
||||||
|
|
||||||
- name: Export binaries from images
|
- name: Export binaries from images
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
@@ -96,7 +96,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
- name: Upload binaries
|
- name: Upload binaries
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: binaries
|
name: binaries
|
||||||
path: binaries/wg-portal_linux*
|
path: binaries/wg-portal_linux*
|
||||||
@@ -110,12 +110,12 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Download binaries
|
- name: Download binaries
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: binaries
|
name: binaries
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||||
with:
|
with:
|
||||||
files: 'wg-portal_linux*'
|
files: 'wg-portal_linux*'
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
|
|||||||
4
.github/workflows/pages.yml
vendored
4
.github/workflows/pages.yml
vendored
@@ -15,11 +15,11 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
with:
|
with:
|
||||||
python-version: 3.x
|
python-version: 3.x
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ COPY --from=builder /build/dist/wg-portal /
|
|||||||
######
|
######
|
||||||
# Final image
|
# Final image
|
||||||
######
|
######
|
||||||
FROM alpine:3.22
|
FROM alpine:3.23
|
||||||
# Install OS-level dependencies
|
# Install OS-level dependencies
|
||||||
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools tzdata
|
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools tzdata
|
||||||
# Setup timezone
|
# Setup timezone
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos
|
|||||||
* Docker ready
|
* Docker ready
|
||||||
* Can be used with existing WireGuard setups
|
* Can be used with existing WireGuard setups
|
||||||
* Support for multiple WireGuard interfaces
|
* Support for multiple WireGuard interfaces
|
||||||
* Supports multiple WireGuard backends (wgctrl or MikroTik)
|
* Supports multiple WireGuard backends (wgctrl, MikroTik, or pfSense)
|
||||||
* Peer Expiry Feature
|
* Peer Expiry Feature
|
||||||
* Handles route and DNS settings like wg-quick does
|
* Handles route and DNS settings like wg-quick does
|
||||||
* Exposes Prometheus metrics for monitoring and alerting
|
* Exposes Prometheus metrics for monitoring and alerting
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func main() {
|
|||||||
rawDb, err := adapters.NewDatabase(cfg.Database)
|
rawDb, err := adapters.NewDatabase(cfg.Database)
|
||||||
internal.AssertNoError(err)
|
internal.AssertNoError(err)
|
||||||
|
|
||||||
database, err := adapters.NewSqlRepository(rawDb)
|
database, err := adapters.NewSqlRepository(rawDb, cfg)
|
||||||
internal.AssertNoError(err)
|
internal.AssertNoError(err)
|
||||||
|
|
||||||
wireGuard, err := wireguard.NewControllerManager(cfg)
|
wireGuard, err := wireguard.NewControllerManager(cfg)
|
||||||
@@ -84,7 +84,7 @@ func main() {
|
|||||||
internal.AssertNoError(err)
|
internal.AssertNoError(err)
|
||||||
userManager.StartBackgroundJobs(ctx)
|
userManager.StartBackgroundJobs(ctx)
|
||||||
|
|
||||||
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager)
|
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, cfg.Web.BasePath, eventBus, userManager)
|
||||||
internal.AssertNoError(err)
|
internal.AssertNoError(err)
|
||||||
authenticator.StartBackgroundJobs(ctx)
|
authenticator.StartBackgroundJobs(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ core:
|
|||||||
|
|
||||||
web:
|
web:
|
||||||
external_url: http://localhost:8888
|
external_url: http://localhost:8888
|
||||||
|
base_path: ""
|
||||||
request_logging: true
|
request_logging: true
|
||||||
|
frontend_filepath: ""
|
||||||
|
|
||||||
|
mail:
|
||||||
|
templates_path: ""
|
||||||
|
|
||||||
webhook:
|
webhook:
|
||||||
url: ""
|
url: ""
|
||||||
@@ -94,3 +99,15 @@ auth:
|
|||||||
admin_group_regex: ^admin-group-name$
|
admin_group_regex: ^admin-group-name$
|
||||||
registration_enabled: true
|
registration_enabled: true
|
||||||
log_user_info: true
|
log_user_info: true
|
||||||
|
|
||||||
|
backend:
|
||||||
|
default: local
|
||||||
|
pfsense:
|
||||||
|
- id: pfsense1
|
||||||
|
display_name: "Main pfSense Firewall"
|
||||||
|
api_url: "https://pfsense.example.com" # Base URL without /api/v2 (endpoints already include it)
|
||||||
|
api_key: "your-api-key" # Generate in pfSense under 'System' -> 'REST API' -> 'Keys'
|
||||||
|
api_verify_tls: true
|
||||||
|
api_timeout: 30s
|
||||||
|
concurrency: 5
|
||||||
|
debug: false
|
||||||
@@ -2,7 +2,7 @@ apiVersion: v2
|
|||||||
name: wg-portal
|
name: wg-portal
|
||||||
description: WireGuard Configuration Portal with LDAP, OAuth, OIDC authentication
|
description: WireGuard Configuration Portal with LDAP, OAuth, OIDC authentication
|
||||||
# Version is set to ensure compatibility with the chart's Ingress resource.
|
# Version is set to ensure compatibility with the chart's Ingress resource.
|
||||||
kubeVersion: ">=1.19.0"
|
kubeVersion: ">=1.19.0-0"
|
||||||
type: application
|
type: application
|
||||||
home: https://wgportal.org
|
home: https://wgportal.org
|
||||||
icon: https://wgportal.org/latest/assets/images/logo.svg
|
icon: https://wgportal.org/latest/assets/images/logo.svg
|
||||||
@@ -16,7 +16,7 @@ annotations:
|
|||||||
# This is the chart version. This version number should be incremented each time you make changes
|
# This is the chart version. This version number should be incremented each time you make changes
|
||||||
# to the chart and its templates, including the app version.
|
# to the chart and its templates, including the app version.
|
||||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||||
version: 0.7.1
|
version: 0.7.2
|
||||||
|
|
||||||
# This is the version number of the application being deployed. This version number should be
|
# This is the version number of the application being deployed. This version number should be
|
||||||
# incremented each time you make changes to the application. Versions are not expected to
|
# incremented each time you make changes to the application. Versions are not expected to
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# wg-portal
|
# wg-portal
|
||||||
|
|
||||||
  
|
  
|
||||||
|
|
||||||
WireGuard Configuration Portal with LDAP, OAuth, OIDC authentication
|
WireGuard Configuration Portal with LDAP, OAuth, OIDC authentication
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ WireGuard Configuration Portal with LDAP, OAuth, OIDC authentication
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
Kubernetes: `>=1.19.0`
|
Kubernetes: `>=1.19.0-0`
|
||||||
|
|
||||||
## Installing the Chart
|
## Installing the Chart
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 131 KiB |
@@ -67,8 +67,7 @@ auth:
|
|||||||
auth:
|
auth:
|
||||||
ldap:
|
ldap:
|
||||||
# a sample LDAP provider with user sync enabled
|
# a sample LDAP provider with user sync enabled
|
||||||
- id: ldap
|
- provider_name: ldap
|
||||||
provider_name: Active Directory
|
|
||||||
url: ldap://srv-ad1.company.local:389
|
url: ldap://srv-ad1.company.local:389
|
||||||
bind_user: ldap_wireguard@company.local
|
bind_user: ldap_wireguard@company.local
|
||||||
bind_pass: super-s3cr3t-ldap
|
bind_pass: super-s3cr3t-ldap
|
||||||
@@ -99,8 +98,7 @@ auth:
|
|||||||
oidc:
|
oidc:
|
||||||
# A sample Entra ID provider with environment variable substitution.
|
# A sample Entra ID provider with environment variable substitution.
|
||||||
# Only users with an @outlook.com email address are allowed to register or login.
|
# Only users with an @outlook.com email address are allowed to register or login.
|
||||||
- id: azure
|
- provider_name: azure
|
||||||
provider_name: azure
|
|
||||||
display_name: Login with</br>Entra ID
|
display_name: Login with</br>Entra ID
|
||||||
registration_enabled: true
|
registration_enabled: true
|
||||||
base_url: "https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0"
|
base_url: "https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0"
|
||||||
@@ -113,8 +111,7 @@ auth:
|
|||||||
- email
|
- email
|
||||||
|
|
||||||
# a sample provider where users with the attribute `wg_admin` set to `true` are considered as admins
|
# a sample provider where users with the attribute `wg_admin` set to `true` are considered as admins
|
||||||
- id: oidc-with-admin-attribute
|
- provider_name: google
|
||||||
provider_name: google
|
|
||||||
display_name: Login with</br>Google
|
display_name: Login with</br>Google
|
||||||
base_url: https://accounts.google.com
|
base_url: https://accounts.google.com
|
||||||
client_id: the-client-id-1234.apps.googleusercontent.com
|
client_id: the-client-id-1234.apps.googleusercontent.com
|
||||||
@@ -136,8 +133,7 @@ auth:
|
|||||||
log_user_info: true
|
log_user_info: true
|
||||||
|
|
||||||
# a sample provider where users in the group `the-admin-group` are considered as admins
|
# a sample provider where users in the group `the-admin-group` are considered as admins
|
||||||
- id: oidc-with-admin-group
|
- provider_name: google2
|
||||||
provider_name: google2
|
|
||||||
display_name: Login with</br>Google2
|
display_name: Login with</br>Google2
|
||||||
base_url: https://accounts.google.com
|
base_url: https://accounts.google.com
|
||||||
client_id: another-client-id-1234.apps.googleusercontent.com
|
client_id: another-client-id-1234.apps.googleusercontent.com
|
||||||
@@ -168,8 +164,7 @@ auth:
|
|||||||
oauth:
|
oauth:
|
||||||
# a sample provider where users with the attribute `this-attribute-must-be-true` set to `true` or `True`
|
# a sample provider where users with the attribute `this-attribute-must-be-true` set to `true` or `True`
|
||||||
# are considered as admins
|
# are considered as admins
|
||||||
- id: google_plain_oauth-with-admin-attribute
|
- provider_name: google3
|
||||||
provider_name: google3
|
|
||||||
display_name: Login with</br>Google3
|
display_name: Login with</br>Google3
|
||||||
client_id: another-client-id-1234.apps.googleusercontent.com
|
client_id: another-client-id-1234.apps.googleusercontent.com
|
||||||
client_secret: A_CLIENT_SECRET
|
client_secret: A_CLIENT_SECRET
|
||||||
@@ -191,8 +186,7 @@ auth:
|
|||||||
|
|
||||||
# a sample provider where either users with the attribute `this-attribute-must-be-true` set to `true` or
|
# a sample provider where either users with the attribute `this-attribute-must-be-true` set to `true` or
|
||||||
# users in the group `admin-group-name` are considered as admins
|
# users in the group `admin-group-name` are considered as admins
|
||||||
- id: google_plain_oauth_with_groups
|
- provider_name: google4
|
||||||
provider_name: google4
|
|
||||||
display_name: Login with</br>Google4
|
display_name: Login with</br>Google4
|
||||||
client_id: another-client-id-1234.apps.googleusercontent.com
|
client_id: another-client-id-1234.apps.googleusercontent.com
|
||||||
client_secret: A_CLIENT_SECRET
|
client_secret: A_CLIENT_SECRET
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ mail:
|
|||||||
auth_type: plain
|
auth_type: plain
|
||||||
from: Wireguard Portal <noreply@wireguard.local>
|
from: Wireguard Portal <noreply@wireguard.local>
|
||||||
link_only: false
|
link_only: false
|
||||||
|
allow_peer_email: false
|
||||||
|
templates_path: ""
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
oidc: []
|
oidc: []
|
||||||
@@ -86,6 +88,7 @@ auth:
|
|||||||
web:
|
web:
|
||||||
listening_address: :8888
|
listening_address: :8888
|
||||||
external_url: http://localhost:8888
|
external_url: http://localhost:8888
|
||||||
|
base_path: ""
|
||||||
site_company_name: WireGuard Portal
|
site_company_name: WireGuard Portal
|
||||||
site_title: WireGuard Portal
|
site_title: WireGuard Portal
|
||||||
session_identifier: wgPortalSession
|
session_identifier: wgPortalSession
|
||||||
@@ -95,6 +98,7 @@ web:
|
|||||||
expose_host_info: false
|
expose_host_info: false
|
||||||
cert_file: ""
|
cert_file: ""
|
||||||
key_File: ""
|
key_File: ""
|
||||||
|
frontend_filepath: ""
|
||||||
|
|
||||||
webhook:
|
webhook:
|
||||||
url: ""
|
url: ""
|
||||||
@@ -126,51 +130,65 @@ More advanced options are found in the subsequent `Advanced` section.
|
|||||||
|
|
||||||
### `admin_user`
|
### `admin_user`
|
||||||
- **Default:** `admin@wgportal.local`
|
- **Default:** `admin@wgportal.local`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_CORE_ADMIN_USER`
|
||||||
- **Description:** The administrator user. This user will be created as a default admin if it does not yet exist.
|
- **Description:** The administrator user. This user will be created as a default admin if it does not yet exist.
|
||||||
|
|
||||||
### `admin_password`
|
### `admin_password`
|
||||||
- **Default:** `wgportal-default`
|
- **Default:** `wgportal-default`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_CORE_ADMIN_PASSWORD`
|
||||||
- **Description:** The administrator password. The default password should be changed immediately!
|
- **Description:** The administrator password. The default password should be changed immediately!
|
||||||
- **Important:** The password should be strong and secure. The minimum password length is specified in [auth.min_password_length](#min_password_length). By default, it is 16 characters.
|
- **Important:** The password should be strong and secure. The minimum password length is specified in [auth.min_password_length](#min_password_length). By default, it is 16 characters.
|
||||||
|
|
||||||
### `disable_admin_user`
|
### `disable_admin_user`
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_CORE_DISABLE_ADMIN_USER`
|
||||||
- **Description:** If `true`, no admin user is created. This is useful if you plan to manage users exclusively through external authentication providers such as LDAP or OAuth.
|
- **Description:** If `true`, no admin user is created. This is useful if you plan to manage users exclusively through external authentication providers such as LDAP or OAuth.
|
||||||
|
|
||||||
### `admin_api_token`
|
### `admin_api_token`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
|
- **Environment Variable:** `WG_PORTAL_CORE_ADMIN_API_TOKEN`
|
||||||
- **Description:** An API token for the admin user. If a token is provided, the REST API can be accessed using this token. If empty, the API is initially disabled for the admin user.
|
- **Description:** An API token for the admin user. If a token is provided, the REST API can be accessed using this token. If empty, the API is initially disabled for the admin user.
|
||||||
|
|
||||||
### `editable_keys`
|
### `editable_keys`
|
||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
|
- **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`
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
- **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for **all** server interfaces.
|
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER`
|
||||||
|
- **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for all server interfaces where the "Create default peer" flag is set.
|
||||||
|
- **Important:** This option is only effective for interfaces where the "Create default peer" flag is set (via the UI).
|
||||||
|
|
||||||
### `create_default_peer_on_creation`
|
### `create_default_peer_on_creation`
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
- **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.
|
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION`
|
||||||
|
- **Description:** If an LDAP user is created (e.g., through LDAP sync) and has no peers, automatically create a new WireGuard peer for all server interfaces where the "Create default peer" flag is set.
|
||||||
|
- **Important:** This option requires [create_default_peer](#create_default_peer) to be enabled.
|
||||||
|
|
||||||
### `re_enable_peer_after_user_enable`
|
### `re_enable_peer_after_user_enable`
|
||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_CORE_RE_ENABLE_PEER_AFTER_USER_ENABLE`
|
||||||
- **Description:** Re-enable all peers that were previously disabled if the associated user is re-enabled.
|
- **Description:** Re-enable all peers that were previously disabled if the associated user is re-enabled.
|
||||||
|
|
||||||
### `delete_peer_after_user_deleted`
|
### `delete_peer_after_user_deleted`
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_CORE_DELETE_PEER_AFTER_USER_DELETED`
|
||||||
- **Description:** If a user is deleted, remove all linked peers. Otherwise, peers remain but are disabled.
|
- **Description:** If a user is deleted, remove all linked peers. Otherwise, peers remain but are disabled.
|
||||||
|
|
||||||
### `self_provisioning_allowed`
|
### `self_provisioning_allowed`
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_CORE_SELF_PROVISIONING_ALLOWED`
|
||||||
- **Description:** Allow registered (non-admin) users to self-provision peers from their profile page.
|
- **Description:** Allow registered (non-admin) users to self-provision peers from their profile page.
|
||||||
|
|
||||||
### `import_existing`
|
### `import_existing`
|
||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_CORE_IMPORT_EXISTING`
|
||||||
- **Description:** On startup, import existing WireGuard interfaces and peers into WireGuard Portal.
|
- **Description:** On startup, import existing WireGuard interfaces and peers into WireGuard Portal.
|
||||||
|
|
||||||
### `restore_state`
|
### `restore_state`
|
||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_CORE_RESTORE_STATE`
|
||||||
- **Description:** Restore the WireGuard interface states (up/down) that existed before WireGuard Portal started.
|
- **Description:** Restore the WireGuard interface states (up/down) that existed before WireGuard Portal started.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -187,11 +205,14 @@ The current MikroTik backend is in **BETA** and may not support all features.
|
|||||||
|
|
||||||
### `local_resolvconf_prefix`
|
### `local_resolvconf_prefix`
|
||||||
- **Default:** `tun.`
|
- **Default:** `tun.`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_BACKEND_LOCAL_RESOLVCONF_PREFIX`
|
||||||
- **Description:** Interface name prefix for WireGuard interfaces on the local system which is used to configure DNS servers with *resolvconf*.
|
- **Description:** Interface name prefix for WireGuard interfaces on the local system which is used to configure DNS servers with *resolvconf*.
|
||||||
It depends on the *resolvconf* implementation you are using, most use a prefix of `tun.`, but some have an empty prefix (e.g., systemd).
|
It depends on the *resolvconf* implementation you are using, most use a prefix of `tun.`, but some have an empty prefix (e.g., systemd).
|
||||||
|
|
||||||
### `ignored_local_interfaces`
|
### `ignored_local_interfaces`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
|
- **Environment Variable:** `WG_PORTAL_BACKEND_IGNORED_LOCAL_INTERFACES`
|
||||||
|
(comma-separated values)
|
||||||
- **Description:** A list of interface names to exclude when enumerating local interfaces.
|
- **Description:** A list of interface names to exclude when enumerating local interfaces.
|
||||||
This is useful if you want to prevent certain interfaces from being imported from the local system.
|
This is useful if you want to prevent certain interfaces from being imported from the local system.
|
||||||
|
|
||||||
@@ -255,54 +276,67 @@ Additional or more specialized configuration options for logging and interface c
|
|||||||
|
|
||||||
### `log_level`
|
### `log_level`
|
||||||
- **Default:** `info`
|
- **Default:** `info`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_ADVANCED_LOG_LEVEL`
|
||||||
- **Description:** The log level used by the application. Valid options are: `trace`, `debug`, `info`, `warn`, `error`.
|
- **Description:** The log level used by the application. Valid options are: `trace`, `debug`, `info`, `warn`, `error`.
|
||||||
|
|
||||||
### `log_pretty`
|
### `log_pretty`
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_ADVANCED_LOG_PRETTY`
|
||||||
- **Description:** If `true`, log messages are colorized and formatted for readability (pretty-print).
|
- **Description:** If `true`, log messages are colorized and formatted for readability (pretty-print).
|
||||||
|
|
||||||
### `log_json`
|
### `log_json`
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_ADVANCED_LOG_JSON`
|
||||||
- **Description:** If `true`, log messages are structured in JSON format.
|
- **Description:** If `true`, log messages are structured in JSON format.
|
||||||
|
|
||||||
### `start_listen_port`
|
### `start_listen_port`
|
||||||
- **Default:** `51820`
|
- **Default:** `51820`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_ADVANCED_START_LISTEN_PORT`
|
||||||
- **Description:** The first port to use when automatically creating new WireGuard interfaces.
|
- **Description:** The first port to use when automatically creating new WireGuard interfaces.
|
||||||
|
|
||||||
### `start_cidr_v4`
|
### `start_cidr_v4`
|
||||||
- **Default:** `10.11.12.0/24`
|
- **Default:** `10.11.12.0/24`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_ADVANCED_START_CIDR_V4`
|
||||||
- **Description:** The initial IPv4 subnet to use when automatically creating new WireGuard interfaces.
|
- **Description:** The initial IPv4 subnet to use when automatically creating new WireGuard interfaces.
|
||||||
|
|
||||||
### `start_cidr_v6`
|
### `start_cidr_v6`
|
||||||
- **Default:** `fdfd:d3ad:c0de:1234::0/64`
|
- **Default:** `fdfd:d3ad:c0de:1234::0/64`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_ADVANCED_START_CIDR_V6`
|
||||||
- **Description:** The initial IPv6 subnet to use when automatically creating new WireGuard interfaces.
|
- **Description:** The initial IPv6 subnet to use when automatically creating new WireGuard interfaces.
|
||||||
|
|
||||||
### `use_ip_v6`
|
### `use_ip_v6`
|
||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_ADVANCED_USE_IP_V6`
|
||||||
- **Description:** Enable or disable IPv6 support.
|
- **Description:** Enable or disable IPv6 support.
|
||||||
|
|
||||||
### `config_storage_path`
|
### `config_storage_path`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
|
- **Environment Variable:** `WG_PORTAL_ADVANCED_CONFIG_STORAGE_PATH`
|
||||||
- **Description:** Path to a directory where `wg-quick` style configuration files will be stored (if you need local filesystem configs).
|
- **Description:** Path to a directory where `wg-quick` style configuration files will be stored (if you need local filesystem configs).
|
||||||
|
|
||||||
### `expiry_check_interval`
|
### `expiry_check_interval`
|
||||||
- **Default:** `15m`
|
- **Default:** `15m`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_ADVANCED_EXPIRY_CHECK_INTERVAL`
|
||||||
- **Description:** Interval after which existing peers are checked if they are expired. Format uses `s`, `m`, `h`, `d` for seconds, minutes, hours, days, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
|
- **Description:** Interval after which existing peers are checked if they are expired. Format uses `s`, `m`, `h`, `d` for seconds, minutes, hours, days, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
|
||||||
|
|
||||||
### `rule_prio_offset`
|
### `rule_prio_offset`
|
||||||
- **Default:** `20000`
|
- **Default:** `20000`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_ADVANCED_RULE_PRIO_OFFSET`
|
||||||
- **Description:** Offset for IP route rule priorities when configuring routing.
|
- **Description:** Offset for IP route rule priorities when configuring routing.
|
||||||
|
|
||||||
### `route_table_offset`
|
### `route_table_offset`
|
||||||
- **Default:** `20000`
|
- **Default:** `20000`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_ADVANCED_ROUTE_TABLE_OFFSET`
|
||||||
- **Description:** Offset for IP route table IDs when configuring routing.
|
- **Description:** Offset for IP route table IDs when configuring routing.
|
||||||
|
|
||||||
### `api_admin_only`
|
### `api_admin_only`
|
||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_ADVANCED_API_ADMIN_ONLY`
|
||||||
- **Description:** If `true`, the public REST API is accessible only to admin users. The API docs live at [`/api/v1/doc.html`](../rest-api/api-doc.md).
|
- **Description:** If `true`, the public REST API is accessible only to admin users. The API docs live at [`/api/v1/doc.html`](../rest-api/api-doc.md).
|
||||||
|
|
||||||
### `limit_additional_user_peers`
|
### `limit_additional_user_peers`
|
||||||
- **Default:** `0`
|
- **Default:** `0`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_ADVANCED_LIMIT_ADDITIONAL_USER_PEERS`
|
||||||
- **Description:** Limit additional peers a normal user can create. `0` means unlimited.
|
- **Description:** Limit additional peers a normal user can create. `0` means unlimited.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -316,18 +350,22 @@ If sensitive values (like private keys) should be stored in an encrypted format,
|
|||||||
|
|
||||||
### `debug`
|
### `debug`
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_DATABASE_DEBUG`
|
||||||
- **Description:** If `true`, logs all database statements (verbose).
|
- **Description:** If `true`, logs all database statements (verbose).
|
||||||
|
|
||||||
### `slow_query_threshold`
|
### `slow_query_threshold`
|
||||||
- **Default:** "0"
|
- **Default:** "0"
|
||||||
|
- **Environment Variable:** `WG_PORTAL_DATABASE_SLOW_QUERY_THRESHOLD`
|
||||||
- **Description:** A time threshold (e.g., `100ms`) above which queries are considered slow and logged as warnings. If zero, slow query logging is disabled. Format uses `s`, `ms` for seconds, milliseconds, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration). The value must be a string.
|
- **Description:** A time threshold (e.g., `100ms`) above which queries are considered slow and logged as warnings. If zero, slow query logging is disabled. Format uses `s`, `ms` for seconds, milliseconds, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration). The value must be a string.
|
||||||
|
|
||||||
### `type`
|
### `type`
|
||||||
- **Default:** `sqlite`
|
- **Default:** `sqlite`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_DATABASE_TYPE`
|
||||||
- **Description:** The database type. Valid options: `sqlite`, `mssql`, `mysql`, `postgres`.
|
- **Description:** The database type. Valid options: `sqlite`, `mssql`, `mysql`, `postgres`.
|
||||||
|
|
||||||
### `dsn`
|
### `dsn`
|
||||||
- **Default:** `data/sqlite.db`
|
- **Default:** `data/sqlite.db`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_DATABASE_DSN`
|
||||||
- **Description:** The Data Source Name (DSN) for connecting to the database.
|
- **Description:** The Data Source Name (DSN) for connecting to the database.
|
||||||
For example:
|
For example:
|
||||||
```text
|
```text
|
||||||
@@ -336,6 +374,7 @@ If sensitive values (like private keys) should be stored in an encrypted format,
|
|||||||
|
|
||||||
### `encryption_passphrase`
|
### `encryption_passphrase`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
|
- **Environment Variable:** `WG_PORTAL_DATABASE_ENCRYPTION_PASSPHRASE`
|
||||||
- **Description:** Passphrase for encrypting sensitive values such as private keys in the database. Encryption is only applied if this passphrase is set.
|
- **Description:** Passphrase for encrypting sensitive values such as private keys in the database. Encryption is only applied if this passphrase is set.
|
||||||
**Important:** Once you enable encryption by setting this passphrase, you cannot disable it or change it afterward.
|
**Important:** Once you enable encryption by setting this passphrase, you cannot disable it or change it afterward.
|
||||||
New or updated records will be encrypted; existing data remains in plaintext until it’s next modified.
|
New or updated records will be encrypted; existing data remains in plaintext until it’s next modified.
|
||||||
@@ -348,38 +387,47 @@ Controls how WireGuard Portal collects and reports usage statistics, including p
|
|||||||
|
|
||||||
### `use_ping_checks`
|
### `use_ping_checks`
|
||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_STATISTICS_USE_PING_CHECKS`
|
||||||
- **Description:** Enable periodic ping checks to verify that peers remain responsive.
|
- **Description:** Enable periodic ping checks to verify that peers remain responsive.
|
||||||
|
|
||||||
### `ping_check_workers`
|
### `ping_check_workers`
|
||||||
- **Default:** `10`
|
- **Default:** `10`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_STATISTICS_PING_CHECK_WORKERS`
|
||||||
- **Description:** Number of parallel worker processes for ping checks.
|
- **Description:** Number of parallel worker processes for ping checks.
|
||||||
|
|
||||||
### `ping_unprivileged`
|
### `ping_unprivileged`
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_STATISTICS_PING_UNPRIVILEGED`
|
||||||
- **Description:** If `false`, ping checks run without root privileges. This is currently considered BETA.
|
- **Description:** If `false`, ping checks run without root privileges. This is currently considered BETA.
|
||||||
|
|
||||||
### `ping_check_interval`
|
### `ping_check_interval`
|
||||||
- **Default:** `1m`
|
- **Default:** `1m`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_STATISTICS_PING_CHECK_INTERVAL`
|
||||||
- **Description:** Interval between consecutive ping checks for all peers. Format uses `s`, `m`, `h`, `d` for seconds, minutes, hours, days, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
|
- **Description:** Interval between consecutive ping checks for all peers. Format uses `s`, `m`, `h`, `d` for seconds, minutes, hours, days, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
|
||||||
|
|
||||||
### `data_collection_interval`
|
### `data_collection_interval`
|
||||||
- **Default:** `1m`
|
- **Default:** `1m`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_STATISTICS_DATA_COLLECTION_INTERVAL`
|
||||||
- **Description:** Interval between data collection cycles (bytes sent/received, handshake times, etc.). Format uses `s`, `m`, `h`, `d` for seconds, minutes, hours, days, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
|
- **Description:** Interval between data collection cycles (bytes sent/received, handshake times, etc.). Format uses `s`, `m`, `h`, `d` for seconds, minutes, hours, days, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
|
||||||
|
|
||||||
### `collect_interface_data`
|
### `collect_interface_data`
|
||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_STATISTICS_COLLECT_INTERFACE_DATA`
|
||||||
- **Description:** If `true`, collects interface-level data (bytes in/out) for monitoring and statistics.
|
- **Description:** If `true`, collects interface-level data (bytes in/out) for monitoring and statistics.
|
||||||
|
|
||||||
### `collect_peer_data`
|
### `collect_peer_data`
|
||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_STATISTICS_COLLECT_PEER_DATA`
|
||||||
- **Description:** If `true`, collects peer-level data (bytes, last handshake, endpoint, etc.).
|
- **Description:** If `true`, collects peer-level data (bytes, last handshake, endpoint, etc.).
|
||||||
|
|
||||||
### `collect_audit_data`
|
### `collect_audit_data`
|
||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_STATISTICS_COLLECT_AUDIT_DATA`
|
||||||
- **Description:** If `true`, logs certain portal events (such as user logins) to the database.
|
- **Description:** If `true`, logs certain portal events (such as user logins) to the database.
|
||||||
|
|
||||||
### `listening_address`
|
### `listening_address`
|
||||||
- **Default:** `:8787`
|
- **Default:** `:8787`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_STATISTICS_LISTENING_ADDRESS`
|
||||||
- **Description:** Address and port for the integrated Prometheus metric server (e.g., `:8787` or `127.0.0.1:8787`).
|
- **Description:** Address and port for the integrated Prometheus metric server (e.g., `:8787` or `127.0.0.1:8787`).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -387,43 +435,66 @@ Controls how WireGuard Portal collects and reports usage statistics, including p
|
|||||||
## Mail
|
## Mail
|
||||||
|
|
||||||
Options for configuring email notifications or sending peer configurations via email.
|
Options for configuring email notifications or sending peer configurations via email.
|
||||||
|
By default, emails will only be sent to peers that have a valid user record linked.
|
||||||
|
To send emails to all peers that have a valid email-address as user-identifier, set `allow_peer_email` to `true`.
|
||||||
|
|
||||||
### `host`
|
### `host`
|
||||||
- **Default:** `127.0.0.1`
|
- **Default:** `127.0.0.1`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_MAIL_HOST`
|
||||||
- **Description:** Hostname or IP of the SMTP server.
|
- **Description:** Hostname or IP of the SMTP server.
|
||||||
|
|
||||||
### `port`
|
### `port`
|
||||||
- **Default:** `25`
|
- **Default:** `25`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_MAIL_PORT`
|
||||||
- **Description:** Port number for the SMTP server.
|
- **Description:** Port number for the SMTP server.
|
||||||
|
|
||||||
### `encryption`
|
### `encryption`
|
||||||
- **Default:** `none`
|
- **Default:** `none`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_MAIL_ENCRYPTION`
|
||||||
- **Description:** SMTP encryption type. Valid values: `none`, `tls`, `starttls`.
|
- **Description:** SMTP encryption type. Valid values: `none`, `tls`, `starttls`.
|
||||||
|
|
||||||
### `cert_validation`
|
### `cert_validation`
|
||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_MAIL_CERT_VALIDATION`
|
||||||
- **Description:** If `true`, validate the SMTP server certificate (relevant if `encryption` = `tls`).
|
- **Description:** If `true`, validate the SMTP server certificate (relevant if `encryption` = `tls`).
|
||||||
|
|
||||||
### `username`
|
### `username`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
|
- **Environment Variable:** `WG_PORTAL_MAIL_USERNAME`
|
||||||
- **Description:** Optional SMTP username for authentication.
|
- **Description:** Optional SMTP username for authentication.
|
||||||
|
|
||||||
### `password`
|
### `password`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
|
- **Environment Variable:** `WG_PORTAL_MAIL_PASSWORD`
|
||||||
- **Description:** Optional SMTP password for authentication.
|
- **Description:** Optional SMTP password for authentication.
|
||||||
|
|
||||||
### `auth_type`
|
### `auth_type`
|
||||||
- **Default:** `plain`
|
- **Default:** `plain`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_MAIL_AUTH_TYPE`
|
||||||
- **Description:** SMTP authentication type. Valid values: `plain`, `login`, `crammd5`.
|
- **Description:** SMTP authentication type. Valid values: `plain`, `login`, `crammd5`.
|
||||||
|
|
||||||
### `from`
|
### `from`
|
||||||
- **Default:** `Wireguard Portal <noreply@wireguard.local>`
|
- **Default:** `Wireguard Portal <noreply@wireguard.local>`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_MAIL_FROM`
|
||||||
- **Description:** The default "From" address when sending emails.
|
- **Description:** The default "From" address when sending emails.
|
||||||
|
|
||||||
### `link_only`
|
### `link_only`
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_MAIL_LINK_ONLY`
|
||||||
- **Description:** If `true`, emails only contain a link to WireGuard Portal, rather than attaching the full configuration.
|
- **Description:** If `true`, emails only contain a link to WireGuard Portal, rather than attaching the full configuration.
|
||||||
|
|
||||||
|
### `allow_peer_email`
|
||||||
|
- **Default:** `false`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_MAIL_ALLOW_PEER_EMAIL`
|
||||||
|
- **Description:** If `true`, and a peer has no valid user record linked, but the user-identifier of the peer is a valid email address, emails will be sent to that email address.
|
||||||
|
If false, and the peer has no valid user record linked, emails will not be sent.
|
||||||
|
If a peer has linked a valid user, the email address is always taken from the user record.
|
||||||
|
|
||||||
|
### `templates_path`
|
||||||
|
- **Default:** *(empty)*
|
||||||
|
- **Environment Variable:** `WG_PORTAL_MAIL_TEMPLATES_PATH`
|
||||||
|
- **Description:** Path to the email template files that override embedded templates. Check [usage documentation](../usage/mail-templates.md) for an example.`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
@@ -435,12 +506,14 @@ Some core authentication options are shared across all providers, while others a
|
|||||||
|
|
||||||
### `min_password_length`
|
### `min_password_length`
|
||||||
- **Default:** `16`
|
- **Default:** `16`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_AUTH_MIN_PASSWORD_LENGTH`
|
||||||
- **Description:** Minimum password length for local authentication. This is not enforced for LDAP authentication.
|
- **Description:** Minimum password length for local authentication. This is not enforced for LDAP authentication.
|
||||||
The default admin password strength is also enforced by this setting.
|
The default admin password strength is also enforced by this setting.
|
||||||
- **Important:** The password should be strong and secure. It is recommended to use a password with at least 16 characters, including uppercase and lowercase letters, numbers, and special characters.
|
- **Important:** The password should be strong and secure. It is recommended to use a password with at least 16 characters, including uppercase and lowercase letters, numbers, and special characters.
|
||||||
|
|
||||||
### `hide_login_form`
|
### `hide_login_form`
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_AUTH_HIDE_LOGIN_FORM`
|
||||||
- **Description:** If `true`, the login form is hidden and only the OIDC, OAuth, LDAP, or WebAuthn providers are shown. This is useful if you want to enforce a specific authentication method.
|
- **Description:** If `true`, the login form is hidden and only the OIDC, OAuth, LDAP, or WebAuthn providers are shown. This is useful if you want to enforce a specific authentication method.
|
||||||
If no social login providers are configured, the login form is always shown, regardless of this setting.
|
If no social login providers are configured, the login form is always shown, regardless of this setting.
|
||||||
- **Important:** You can still access the login form by adding the `?all` query parameter to the login URL (e.g. https://wg.portal/#/login?all).
|
- **Important:** You can still access the login form by adding the `?all` query parameter to the login URL (e.g. https://wg.portal/#/login?all).
|
||||||
@@ -503,13 +576,18 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
|
|||||||
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
|
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
|
||||||
|
|
||||||
#### `registration_enabled`
|
#### `registration_enabled`
|
||||||
- **Default:** *(empty)*
|
- **Default:** `false`
|
||||||
- **Description:** If `true`, a new user will be created in WireGuard Portal if not already present.
|
- **Description:** If `true`, a new user will be created in WireGuard Portal if not already present.
|
||||||
|
|
||||||
#### `log_user_info`
|
#### `log_user_info`
|
||||||
- **Default:** *(empty)*
|
- **Default:** `false`
|
||||||
- **Description:** If `true`, OIDC user data is logged at the trace level upon login (for debugging).
|
- **Description:** If `true`, OIDC user data is logged at the trace level upon login (for debugging).
|
||||||
|
|
||||||
|
#### `log_sensitive_info`
|
||||||
|
- **Default:** `false`
|
||||||
|
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
|
||||||
|
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### OAuth
|
### OAuth
|
||||||
@@ -576,13 +654,18 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
|
|||||||
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
|
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
|
||||||
|
|
||||||
#### `registration_enabled`
|
#### `registration_enabled`
|
||||||
- **Default:** *(empty)*
|
- **Default:** `false`
|
||||||
- **Description:** If `true`, new users are created automatically on successful login.
|
- **Description:** If `true`, new users are created automatically on successful login.
|
||||||
|
|
||||||
#### `log_user_info`
|
#### `log_user_info`
|
||||||
- **Default:** *(empty)*
|
- **Default:** `false`
|
||||||
- **Description:** If `true`, logs user info at the trace level upon login.
|
- **Description:** If `true`, logs user info at the trace level upon login.
|
||||||
|
|
||||||
|
#### `log_sensitive_info`
|
||||||
|
- **Default:** `false`
|
||||||
|
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
|
||||||
|
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### LDAP
|
### LDAP
|
||||||
@@ -599,11 +682,11 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
|
|||||||
- **Description:** The LDAP server URL (e.g., `ldap://srv-ad01.company.local:389`).
|
- **Description:** The LDAP server URL (e.g., `ldap://srv-ad01.company.local:389`).
|
||||||
|
|
||||||
#### `start_tls`
|
#### `start_tls`
|
||||||
- **Default:** *(empty)*
|
- **Default:** `false`
|
||||||
- **Description:** If `true`, use STARTTLS to secure the LDAP connection.
|
- **Description:** If `true`, use STARTTLS to secure the LDAP connection.
|
||||||
|
|
||||||
#### `cert_validation`
|
#### `cert_validation`
|
||||||
- **Default:** *(empty)*
|
- **Default:** `false`
|
||||||
- **Description:** If `true`, validate the LDAP server’s TLS certificate.
|
- **Description:** If `true`, validate the LDAP server’s TLS certificate.
|
||||||
|
|
||||||
#### `tls_certificate_path`
|
#### `tls_certificate_path`
|
||||||
@@ -672,20 +755,24 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
|
|||||||
(&(objectClass=organizationalPerson)(!userAccountControl:1.2.840.113556.1.4.803:=2)(mail=*))
|
(&(objectClass=organizationalPerson)(!userAccountControl:1.2.840.113556.1.4.803:=2)(mail=*))
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `sync_log_user_info`
|
||||||
|
- **Default:** `false`
|
||||||
|
- **Description:** If `true`, logs LDAP user data at the trace level during synchronization.
|
||||||
|
|
||||||
#### `disable_missing`
|
#### `disable_missing`
|
||||||
- **Default:** *(empty)*
|
- **Default:** `false`
|
||||||
- **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal.
|
- **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal.
|
||||||
|
|
||||||
#### `auto_re_enable`
|
#### `auto_re_enable`
|
||||||
- **Default:** *(empty)*
|
- **Default:** `false`
|
||||||
- **Description:** If `true`, users that where disabled because they were missing (see `disable_missing`) will be re-enabled once they are found again.
|
- **Description:** If `true`, users that where disabled because they were missing (see `disable_missing`) will be re-enabled once they are found again.
|
||||||
|
|
||||||
#### `registration_enabled`
|
#### `registration_enabled`
|
||||||
- **Default:** *(empty)*
|
- **Default:** `false`
|
||||||
- **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login.
|
- **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login.
|
||||||
|
|
||||||
#### `log_user_info`
|
#### `log_user_info`
|
||||||
- **Default:** *(empty)*
|
- **Default:** `false`
|
||||||
- **Description:** If `true`, logs LDAP user data at the trace level upon login.
|
- **Description:** If `true`, logs LDAP user data at the trace level upon login.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -696,6 +783,7 @@ The `webauthn` section contains configuration options for WebAuthn authenticatio
|
|||||||
|
|
||||||
#### `enabled`
|
#### `enabled`
|
||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_AUTH_WEBAUTHN_ENABLED`
|
||||||
- **Description:** If `true`, Passkey authentication is enabled. If `false`, WebAuthn is disabled.
|
- **Description:** If `true`, Passkey authentication is enabled. If `false`, WebAuthn is disabled.
|
||||||
Users are encouraged to use Passkeys for secure authentication instead of passwords.
|
Users are encouraged to use Passkeys for secure authentication instead of passwords.
|
||||||
If a passkey is registered, the password login is still available as a fallback. Ensure that the password is strong and secure.
|
If a passkey is registered, the password login is still available as a fallback. Ensure that the password is strong and secure.
|
||||||
@@ -708,50 +796,76 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
|
|||||||
|
|
||||||
### `listening_address`
|
### `listening_address`
|
||||||
- **Default:** `:8888`
|
- **Default:** `:8888`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEB_LISTENING_ADDRESS`
|
||||||
- **Description:** The listening address and port for the web server (e.g., `:8888` to bind on all interfaces or `127.0.0.1:8888` to bind only on the loopback interface).
|
- **Description:** The listening address and port for the web server (e.g., `:8888` to bind on all interfaces or `127.0.0.1:8888` to bind only on the loopback interface).
|
||||||
Ensure that access to WireGuard Portal is protected against unauthorized access, especially if binding to all interfaces.
|
Ensure that access to WireGuard Portal is protected against unauthorized access, especially if binding to all interfaces.
|
||||||
|
|
||||||
### `external_url`
|
### `external_url`
|
||||||
- **Default:** `http://localhost:8888`
|
- **Default:** `http://localhost:8888`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEB_EXTERNAL_URL`
|
||||||
- **Description:** The URL where a client can access WireGuard Portal. This URL is used for generating links in emails and for performing OAUTH redirects.
|
- **Description:** The URL where a client can access WireGuard Portal. This URL is used for generating links in emails and for performing OAUTH redirects.
|
||||||
|
The external URL must not contain a path component or trailing slash. If you want to serve WireGuard Portal on a subpath, use the `base_path` setting.
|
||||||
**Important:** If you are using a reverse proxy, set this to the external URL of the reverse proxy, otherwise login will fail. If you access the portal via IP address, set this to the IP address of the server.
|
**Important:** If you are using a reverse proxy, set this to the external URL of the reverse proxy, otherwise login will fail. If you access the portal via IP address, set this to the IP address of the server.
|
||||||
|
|
||||||
|
### `base_path`
|
||||||
|
- **Default:** *(empty)*
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEB_BASE_PATH`
|
||||||
|
- **Description:** The base path for the web server (e.g., `/wgportal`).
|
||||||
|
By default (meaning an empty value), the portal will be served from the root path `/`.
|
||||||
|
|
||||||
### `site_company_name`
|
### `site_company_name`
|
||||||
- **Default:** `WireGuard Portal`
|
- **Default:** `WireGuard Portal`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEB_SITE_COMPANY_NAME`
|
||||||
- **Description:** The company name that is shown at the bottom of the web frontend.
|
- **Description:** The company name that is shown at the bottom of the web frontend.
|
||||||
|
|
||||||
### `site_title`
|
### `site_title`
|
||||||
- **Default:** `WireGuard Portal`
|
- **Default:** `WireGuard Portal`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEB_SITE_TITLE`
|
||||||
- **Description:** The title that is shown in the web frontend.
|
- **Description:** The title that is shown in the web frontend.
|
||||||
|
|
||||||
### `session_identifier`
|
### `session_identifier`
|
||||||
- **Default:** `wgPortalSession`
|
- **Default:** `wgPortalSession`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEB_SESSION_IDENTIFIER`
|
||||||
- **Description:** The session identifier for the web frontend.
|
- **Description:** The session identifier for the web frontend.
|
||||||
|
|
||||||
### `session_secret`
|
### `session_secret`
|
||||||
- **Default:** `very_secret`
|
- **Default:** `very_secret`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEB_SESSION_SECRET`
|
||||||
- **Description:** The session secret for the web frontend.
|
- **Description:** The session secret for the web frontend.
|
||||||
|
|
||||||
### `csrf_secret`
|
### `csrf_secret`
|
||||||
- **Default:** `extremely_secret`
|
- **Default:** `extremely_secret`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEB_CSRF_SECRET`
|
||||||
- **Description:** The CSRF secret.
|
- **Description:** The CSRF secret.
|
||||||
|
|
||||||
### `request_logging`
|
### `request_logging`
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEB_REQUEST_LOGGING`
|
||||||
- **Description:** Log all HTTP requests.
|
- **Description:** Log all HTTP requests.
|
||||||
|
|
||||||
### `expose_host_info`
|
### `expose_host_info`
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEB_EXPOSE_HOST_INFO`
|
||||||
- **Description:** Expose the hostname and version of the WireGuard Portal server in an HTTP header. This is useful for debugging but may expose sensitive information.
|
- **Description:** Expose the hostname and version of the WireGuard Portal server in an HTTP header. This is useful for debugging but may expose sensitive information.
|
||||||
|
|
||||||
### `cert_file`
|
### `cert_file`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEB_CERT_FILE`
|
||||||
- **Description:** (Optional) Path to the TLS certificate file.
|
- **Description:** (Optional) Path to the TLS certificate file.
|
||||||
|
|
||||||
### `key_file`
|
### `key_file`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEB_KEY_FILE`
|
||||||
- **Description:** (Optional) Path to the TLS certificate key file.
|
- **Description:** (Optional) Path to the TLS certificate key file.
|
||||||
|
|
||||||
|
### `frontend_filepath`
|
||||||
|
- **Default:** *(empty)*
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEB_FRONTEND_FILEPATH`
|
||||||
|
- **Description:** Optional base directory from which the web frontend is served. Check out the [building](../getting-started/sources.md) documentation for more information on how to compile the frontend assets.
|
||||||
|
- If the directory contains at least one file (recursively), these files are served at `/app`, overriding the embedded frontend assets.
|
||||||
|
- If the directory is empty or does not exist on startup, the embedded frontend is copied into this directory automatically and then served.
|
||||||
|
- If left empty, the embedded frontend is served and no files are written to disk.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Webhook
|
## Webhook
|
||||||
@@ -761,12 +875,15 @@ Further details can be found in the [usage documentation](../usage/webhooks.md).
|
|||||||
|
|
||||||
### `url`
|
### `url`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEBHOOK_URL`
|
||||||
- **Description:** The POST endpoint to which the webhook is sent. The URL must be reachable from the WireGuard Portal server. If the URL is empty, the webhook is disabled.
|
- **Description:** The POST endpoint to which the webhook is sent. The URL must be reachable from the WireGuard Portal server. If the URL is empty, the webhook is disabled.
|
||||||
|
|
||||||
### `authentication`
|
### `authentication`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEBHOOK_AUTHENTICATION`
|
||||||
- **Description:** The Authorization header for the webhook endpoint. The value is send as-is in the header. For example: `Bearer <token>`.
|
- **Description:** The Authorization header for the webhook endpoint. The value is send as-is in the header. For example: `Bearer <token>`.
|
||||||
|
|
||||||
### `timeout`
|
### `timeout`
|
||||||
- **Default:** `10s`
|
- **Default:** `10s`
|
||||||
|
- **Environment Variable:** `WG_PORTAL_WEBHOOK_TIMEOUT`
|
||||||
- **Description:** The timeout for the webhook request. If the request takes longer than this, it is aborted.
|
- **Description:** The timeout for the webhook request. If the request takes longer than this, it is aborted.
|
||||||
@@ -9,6 +9,11 @@ Make sure that you download the correct binary for your architecture. The availa
|
|||||||
- `wg-portal_linux_arm64` - Linux ARM 64-bit
|
- `wg-portal_linux_arm64` - Linux ARM 64-bit
|
||||||
- `wg-portal_linux_arm_v7` - Linux ARM 32-bit
|
- `wg-portal_linux_arm_v7` - Linux ARM 32-bit
|
||||||
|
|
||||||
|
### Released versions
|
||||||
|
|
||||||
|
To download a specific version, replace `${WG_PORTAL_VERSION}` with the desired version (or set an environment variable).
|
||||||
|
All official release versions can be found on the [GitHub Releases Page](https://github.com/h44z/wg-portal/releases).
|
||||||
|
|
||||||
With `curl`:
|
With `curl`:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
@@ -27,16 +32,74 @@ with `gh cli`:
|
|||||||
gh release download ${WG_PORTAL_VERSION} --repo h44z/wg-portal --output wg-portal --pattern '*amd64'
|
gh release download ${WG_PORTAL_VERSION} --repo h44z/wg-portal --output wg-portal --pattern '*amd64'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The downloaded file will be named `wg-portal` and can be moved to a directory of your choice, see [Install](#install) for more information.
|
||||||
|
|
||||||
|
### Unreleased versions (master branch builds)
|
||||||
|
|
||||||
|
Unreleased versions can be fetched directly from the artifacts section of the [GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster).
|
||||||
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
The following command can be used to install the downloaded binary (`wg-portal`) to `/opt/wg-portal/wg-portal`. It ensures that the binary is executable.
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
sudo mkdir -p /opt/wg-portal
|
sudo mkdir -p /opt/wg-portal
|
||||||
sudo install wg-portal /opt/wg-portal/
|
sudo install wg-portal /opt/wg-portal/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Unreleased versions (master branch builds)
|
To handle tasks such as restarting the service or configuring automatic startup, it is recommended to use a process manager like [systemd](https://systemd.io/).
|
||||||
|
Refer to [Systemd Service Setup](#systemd-service-setup) for instructions.
|
||||||
|
|
||||||
Unreleased versions can be fetched directly from the artifacts section of the [GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster).
|
## Systemd Service Setup
|
||||||
|
|
||||||
|
> **Note:** To run WireGuard Portal as systemd service, you need to download the binary for your architecture beforehand.
|
||||||
|
>
|
||||||
|
> The following examples assume that you downloaded the binary to `/opt/wg-portal/wg-portal`.
|
||||||
|
> The configuration file is expected to be located at `/opt/wg-portal/config.yml`.
|
||||||
|
|
||||||
|
To run WireGuard Portal as a systemd service, you can create a service unit file. The easiest way to do this is by using `systemctl edit`:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
sudo systemctl edit --force --full wg-portal.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the following content into the editor and adjust the variables to your needs:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=WireGuard Portal
|
||||||
|
ConditionPathExists=/opt/wg-portal/wg-portal
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
WorkingDirectory=/opt/wg-portal
|
||||||
|
Environment=WG_PORTAL_CONFIG=/opt/wg-portal/config.yml
|
||||||
|
ExecStart=/opt/wg-portal/wg-portal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, you can create or modify the file manually in `/etc/systemd/system/wg-portal.service`.
|
||||||
|
For systemd to pick up the changes, you need to reload the daemon:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
After creating the service file, you can enable and start the service:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
sudo systemctl enable --now wg-portal.service
|
||||||
|
```
|
||||||
|
|
||||||
|
To check status and log output, use: `sudo systemctl status wg-portal.service` or `sudo journalctl -u wg-portal.service`.
|
||||||
|
|||||||
@@ -84,6 +84,16 @@ web:
|
|||||||
external_url: https://wg.domain.com
|
external_url: https://wg.domain.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you want to serve the web interface on a different base-path, you can also set the `web.base_path` option:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
web:
|
||||||
|
external_url: https://wg.domain.com
|
||||||
|
base_path: /subpath
|
||||||
|
```
|
||||||
|
|
||||||
|
The WireGuard Portal will then be available at `https://wg.domain.com/subpath`.
|
||||||
|
|
||||||
### Built-in TLS
|
### Built-in TLS
|
||||||
|
|
||||||
If you prefer to let WireGuard Portal handle TLS itself, you can use the built-in TLS support.
|
If you prefer to let WireGuard Portal handle TLS itself, you can use the built-in TLS support.
|
||||||
|
|||||||
@@ -443,6 +443,18 @@ definitions:
|
|||||||
maxLength: 64
|
maxLength: 64
|
||||||
minLength: 32
|
minLength: 32
|
||||||
type: string
|
type: string
|
||||||
|
AuthSources:
|
||||||
|
description: The source of the user. This field is optional.
|
||||||
|
example:
|
||||||
|
- db
|
||||||
|
items:
|
||||||
|
enum:
|
||||||
|
- db
|
||||||
|
- ldap
|
||||||
|
- oauth
|
||||||
|
type: string
|
||||||
|
readOnly: true
|
||||||
|
type: array
|
||||||
Department:
|
Department:
|
||||||
description: The department of the user. This field is optional.
|
description: The department of the user. This field is optional.
|
||||||
example: Software Development
|
example: Software Development
|
||||||
@@ -503,17 +515,6 @@ definitions:
|
|||||||
description: The phone number of the user. This field is optional.
|
description: The phone number of the user. This field is optional.
|
||||||
example: "+1234546789"
|
example: "+1234546789"
|
||||||
type: string
|
type: string
|
||||||
ProviderName:
|
|
||||||
description: The name of the authentication provider. This field is read-only.
|
|
||||||
example: ""
|
|
||||||
readOnly: true
|
|
||||||
type: string
|
|
||||||
Source:
|
|
||||||
description: The source of the user. This field is optional.
|
|
||||||
enum:
|
|
||||||
- db
|
|
||||||
example: db
|
|
||||||
type: string
|
|
||||||
required:
|
required:
|
||||||
- Identifier
|
- Identifier
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
152
docs/documentation/usage/authentication.md
Normal file
152
docs/documentation/usage/authentication.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
WireGuard Portal supports multiple authentication mechanisms to manage user access. This includes
|
||||||
|
|
||||||
|
- Local user accounts
|
||||||
|
- LDAP authentication
|
||||||
|
- OAuth2 and OIDC authentication
|
||||||
|
- Passkey authentication (WebAuthn)
|
||||||
|
|
||||||
|
Users can have two roles which limit their permissions in WireGuard Portal:
|
||||||
|
|
||||||
|
- **User**: Can manage their own account and peers.
|
||||||
|
- **Admin**: Can manage all users and peers, including the ability to manage WireGuard interfaces.
|
||||||
|
|
||||||
|
In general, each user is identified by a _unique identifier_. If the same user identifier exists across multiple authentication sources, WireGuard Portal automatically merges those accounts into a single user record.
|
||||||
|
When a user is associated with multiple authentication sources, their information in WireGuard Portal is updated based on the most recently logged-in source. For more details, see [User Synchronization](./user-sync.md) documentation.
|
||||||
|
|
||||||
|
## Password Authentication
|
||||||
|
|
||||||
|
WireGuard Portal supports username and password authentication for both local and LDAP-backed accounts.
|
||||||
|
Local users are stored in the database, while LDAP users are authenticated against an external LDAP server.
|
||||||
|
|
||||||
|
On initial startup, WireGuard Portal automatically creates a local admin account with the password `wgportal-default`.
|
||||||
|
> :warning: This password must be changed immediately after the first login.
|
||||||
|
|
||||||
|
The minimum password length for all local users can be configured in the [`auth`](../configuration/overview.md#auth)
|
||||||
|
section of the configuration file. The default value is **16** characters, see [`min_password_length`](../configuration/overview.md#min_password_length).
|
||||||
|
The minimum password length is also enforced for the default admin user.
|
||||||
|
|
||||||
|
|
||||||
|
## Passkey (WebAuthn) Authentication
|
||||||
|
|
||||||
|
Besides the standard authentication mechanisms, WireGuard Portal supports Passkey authentication.
|
||||||
|
This feature is enabled by default and can be configured in the [`webauthn`](../configuration/overview.md#webauthn-passkeys) section of the configuration file.
|
||||||
|
|
||||||
|
Users can register multiple Passkeys to their account. These Passkeys can be used to log in to the web UI as long as the user is not locked.
|
||||||
|
> :warning: Passkey authentication does not disable password authentication. The password can still be used to log in (e.g., as a fallback).
|
||||||
|
|
||||||
|
To register a Passkey, open the settings page *(1)* in the web UI and click on the "Register Passkey" *(2)* button.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## OAuth2 and OIDC Authentication
|
||||||
|
|
||||||
|
WireGuard Portal supports OAuth2 and OIDC authentication. You can use any OAuth2 or OIDC provider that supports the authorization code flow,
|
||||||
|
such as Google, GitHub, or Keycloak.
|
||||||
|
|
||||||
|
For OAuth2 or OIDC to work, you need to configure the [`external_url`](../configuration/overview.md#external_url) property in the [`web`](../configuration/overview.md#web) section of the configuration file.
|
||||||
|
If you are planning to expose the portal to the internet, make sure that the `external_url` is configured to use HTTPS.
|
||||||
|
|
||||||
|
To add OIDC or OAuth2 authentication to WireGuard Portal, create a Client-ID and Client-Secret in your OAuth2 provider and
|
||||||
|
configure a new authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
|
||||||
|
Make sure that each configured provider has a unique `provider_name` property set. Samples can be seen [here](../configuration/examples.md).
|
||||||
|
|
||||||
|
#### Limiting Login to Specific Domains
|
||||||
|
|
||||||
|
You can limit the login to specific domains by setting the `allowed_domains` property for OAuth2 or OIDC providers.
|
||||||
|
This property is a comma-separated list of domains that are allowed to log in. The user's email address is checked against this list.
|
||||||
|
For example, if you want to allow only users with an email address ending in `outlook.com` to log in, set the property as follows:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
oidc:
|
||||||
|
- provider_name: "oidc1"
|
||||||
|
# ... other settings
|
||||||
|
allowed_domains:
|
||||||
|
- "outlook.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Limit Login to Existing Users
|
||||||
|
|
||||||
|
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for OAuth2 or OIDC providers.
|
||||||
|
If registration is enabled, new users will be created in the database when they log in for the first time.
|
||||||
|
|
||||||
|
#### Admin Mapping
|
||||||
|
|
||||||
|
You can map users to admin roles based on their attributes in the OAuth2 or OIDC provider. To do this, set the `admin_mapping` property for the provider.
|
||||||
|
Administrative access can either be mapped by a specific attribute or by group membership.
|
||||||
|
|
||||||
|
**Attribute specific mapping** can be achieved by setting the `admin_value_regex` and the `is_admin` property.
|
||||||
|
The `admin_value_regex` property is a regular expression that is matched against the value of the `is_admin` attribute.
|
||||||
|
The user is granted admin access if the regex matches the attribute value.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
oidc:
|
||||||
|
- provider_name: "oidc1"
|
||||||
|
# ... other settings
|
||||||
|
field_map:
|
||||||
|
is_admin: "wg_admin_prop"
|
||||||
|
admin_mapping:
|
||||||
|
admin_value_regex: "^true$"
|
||||||
|
```
|
||||||
|
The example above will grant admin access to users with the `wg_admin_prop` attribute set to `true`.
|
||||||
|
|
||||||
|
**Group membership mapping** can be achieved by setting the `admin_group_regex` and `user_groups` property.
|
||||||
|
The `admin_group_regex` property is a regular expression that is matched against the group names of the user.
|
||||||
|
The user is granted admin access if the regex matches any of the group names.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
oidc:
|
||||||
|
- provider_name: "oidc1"
|
||||||
|
# ... other settings
|
||||||
|
field_map:
|
||||||
|
user_groups: "groups"
|
||||||
|
admin_mapping:
|
||||||
|
admin_group_regex: "^the-admin-group$"
|
||||||
|
```
|
||||||
|
The example above will grant admin access to users who are members of the `the-admin-group` group.
|
||||||
|
|
||||||
|
|
||||||
|
## LDAP Authentication
|
||||||
|
|
||||||
|
WireGuard Portal supports LDAP authentication. You can use any LDAP server that supports the LDAP protocol, such as Active Directory or OpenLDAP.
|
||||||
|
Multiple LDAP servers can be configured in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
|
||||||
|
WireGuard Portal remembers the authentication provider of the user and therefore avoids conflicts between multiple LDAP providers.
|
||||||
|
|
||||||
|
To configure LDAP authentication, create a new [`ldap`](../configuration/overview.md#ldap) authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
|
||||||
|
|
||||||
|
### Limiting Login to Specific Users
|
||||||
|
|
||||||
|
You can limit the login to specific users by setting the `login_filter` property for LDAP provider. This filter uses the LDAP search filter syntax.
|
||||||
|
The username can be inserted into the query by placing the `{{login_identifier}}` placeholder in the filter. This placeholder will then be replaced with the username entered by the user during login.
|
||||||
|
|
||||||
|
For example, if you want to allow only users with the `objectClass` attribute set to `organizationalPerson` to log in, set the property as follows:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
ldap:
|
||||||
|
- provider_name: "ldap1"
|
||||||
|
# ... other settings
|
||||||
|
login_filter: "(&(objectClass=organizationalPerson)(uid={{login_identifier}}))"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `login_filter` should always be designed to return at most one user.
|
||||||
|
|
||||||
|
### Limit Login to Existing Users
|
||||||
|
|
||||||
|
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for LDAP providers.
|
||||||
|
If registration is enabled, new users will be created in the database when they log in for the first time.
|
||||||
|
|
||||||
|
### Admin Mapping
|
||||||
|
|
||||||
|
You can map users to admin roles based on their group membership in the LDAP server. To do this, set the `admin_group` and `memberof` property for the provider.
|
||||||
|
The `admin_group` property defines the distinguished name of the group that is allowed to log in as admin.
|
||||||
|
All groups that are listed in the `memberof` attribute of the user will be checked against this group. If one of the groups matches, the user is granted admin access.
|
||||||
|
|
||||||
|
|
||||||
|
## User Synchronization
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ A global default backend determines where newly created interfaces go (unless yo
|
|||||||
**Supported backends:**
|
**Supported backends:**
|
||||||
- **Local** (default): Manages interfaces on the host running WireGuard Portal (Linux WireGuard via wgctrl). Use this when the portal should directly configure wg devices on the same server.
|
- **Local** (default): Manages interfaces on the host running WireGuard Portal (Linux WireGuard via wgctrl). Use this when the portal should directly configure wg devices on the same server.
|
||||||
- **MikroTik** RouterOS (_beta_): Manages interfaces and peers on MikroTik devices via the RouterOS REST API. Use this to control WG interfaces on RouterOS v7+.
|
- **MikroTik** RouterOS (_beta_): Manages interfaces and peers on MikroTik devices via the RouterOS REST API. Use this to control WG interfaces on RouterOS v7+.
|
||||||
|
- **pfSense** (_alpha_): Manages interfaces and peers on pfSense firewalls via the pfSense REST API.
|
||||||
|
|
||||||
How backend selection works:
|
How backend selection works:
|
||||||
- The default backend is configured at `backend.default` (_local_ or the id of a defined MikroTik backend).
|
- The default backend is configured at `backend.default` (_local_ or the id of a defined MikroTik backend).
|
||||||
@@ -55,3 +56,36 @@ backend:
|
|||||||
### Known limitations:
|
### Known limitations:
|
||||||
- The MikroTik backend is still in beta. Some features may not work as expected.
|
- The MikroTik backend is still in beta. Some features may not work as expected.
|
||||||
- Not all WireGuard Portal features are supported yet (e.g., no support for interface hooks)
|
- Not all WireGuard Portal features are supported yet (e.g., no support for interface hooks)
|
||||||
|
|
||||||
|
## Configuring pfSense backends
|
||||||
|
|
||||||
|
> :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.
|
||||||
|
|
||||||
|
### Prerequisites on pfSense:
|
||||||
|
- pfSense with the REST API package enabled (`System -> API`) and WireGuard configured.
|
||||||
|
- An API key with permissions for WireGuard endpoints. If you use a read-only key, set `core.restore_state: false` in `config.yml` to avoid write attempts at startup.
|
||||||
|
- HTTPS recommended; set `api_verify_tls: false` only for lab/self-signed setups.
|
||||||
|
|
||||||
|
Example WireGuard Portal configuration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
backend:
|
||||||
|
# default backend decides where new interfaces are created
|
||||||
|
default: pfsense1
|
||||||
|
|
||||||
|
pfsense:
|
||||||
|
- id: pfsense1 # unique id, not "local"
|
||||||
|
display_name: Main pfSense # optional nice name
|
||||||
|
api_url: https://pfsense.example.com # no trailing /api/v2
|
||||||
|
api_key: your-api-key
|
||||||
|
api_verify_tls: true
|
||||||
|
api_timeout: 30s
|
||||||
|
concurrency: 5
|
||||||
|
debug: false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Known limitations:
|
||||||
|
- Alpha quality: behavior and API coverage may change.
|
||||||
|
- Statistics (rx/tx bytes, last handshake) are not available from the pfSense REST API today.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ WireGuard Interfaces can be categorized into three types:
|
|||||||
## Accessing the Web UI
|
## Accessing the Web UI
|
||||||
|
|
||||||
The web UI should be accessed via the URL specified in the `external_url` property of the configuration file.
|
The web UI should be accessed via the URL specified in the `external_url` property of the configuration file.
|
||||||
By default, WireGuard Portal listens on port `8888` for HTTP connections. Check the [Security](security.md) section for more information on securing the web UI.
|
By default, WireGuard Portal listens on port `8888` for HTTP connections. Check the [Security](security.md) or [Authentication](authentication.md) sections for more information on securing the web UI.
|
||||||
|
|
||||||
So the default URL to access the web UI is:
|
So the default URL to access the web UI is:
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
WireGuard Portal lets you hook up any LDAP server such as Active Directory or OpenLDAP for both authentication and user sync.
|
|
||||||
You can even register multiple LDAP servers side-by-side. When someone logs in via LDAP, their specific provider is remembered,
|
|
||||||
so there's no risk of cross-provider conflicts. Details on the log-in process can be found in the [Security](security.md#ldap-authentication) documentation.
|
|
||||||
|
|
||||||
If you enable LDAP synchronization, all users within the LDAP directory will be created automatically in the WireGuard Portal database if they do not exist.
|
|
||||||
If a user is disabled or deleted in LDAP, the user will be disabled in WireGuard Portal as well.
|
|
||||||
The synchronization process can be fine-tuned by multiple parameters, which are described below.
|
|
||||||
|
|
||||||
## LDAP Synchronization
|
|
||||||
|
|
||||||
WireGuard Portal can automatically synchronize users from LDAP to the database.
|
|
||||||
To enable this feature, set the `sync_interval` property in the LDAP provider configuration to a value greater than "0".
|
|
||||||
The value is a string representing a duration, such as "15m" for 15 minutes or "1h" for 1 hour (check the [exact format definition](https://pkg.go.dev/time#ParseDuration) for details).
|
|
||||||
The synchronization process will run in the background and synchronize users from LDAP to the database at the specified interval.
|
|
||||||
Also make sure that the `sync_filter` property is a well-formed LDAP filter, or synchronization will fail.
|
|
||||||
|
|
||||||
### Limiting Synchronization to Specific Users
|
|
||||||
|
|
||||||
Use the `sync_filter` property in your LDAP provider block to restrict which users get synchronized.
|
|
||||||
It accepts any valid LDAP search filter, only entries matching that filter will be pulled into the portal's database.
|
|
||||||
|
|
||||||
For example, to import only users with a `mail` attribute:
|
|
||||||
```yaml
|
|
||||||
auth:
|
|
||||||
ldap:
|
|
||||||
- id: ldap
|
|
||||||
# ... other settings
|
|
||||||
sync_filter: (mail=*)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Disable Missing Users
|
|
||||||
|
|
||||||
If you set the `disable_missing` property to `true`, any user that is not found in LDAP during synchronization will be disabled in WireGuard Portal.
|
|
||||||
All peers associated with that user will also be disabled.
|
|
||||||
|
|
||||||
If you want a user and its peers to be automatically re-enabled once they are found in LDAP again, set the `auto_re_enable` property to `true`.
|
|
||||||
This will only re-enable the user if they where disabled by the synchronization process. Manually disabled users will not be re-enabled.
|
|
||||||
49
docs/documentation/usage/mail-templates.md
Normal file
49
docs/documentation/usage/mail-templates.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
WireGuard Portal sends emails when you share a configuration with a user.
|
||||||
|
By default, the application uses embedded templates. You can fully customize these emails by pointing the Portal
|
||||||
|
to a folder containing your own templates. If the folder is empty on startup, the default embedded templates
|
||||||
|
are written there to get you started.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
To enable custom templates, set the `mail.templates_path` option in the application configuration file
|
||||||
|
or the `WG_PORTAL_MAIL_TEMPLATES_PATH` environment variable to a valid folder path.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mail:
|
||||||
|
# ... other mail options ...
|
||||||
|
# Path where custom email templates (.gotpl and .gohtml) are stored.
|
||||||
|
# If the directory is empty on startup, the default embedded templates
|
||||||
|
# will be written there so you can modify them.
|
||||||
|
# Leave empty to use embedded templates only.
|
||||||
|
templates_path: "/opt/wg-portal/mail-templates"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Template files and names
|
||||||
|
|
||||||
|
The system expects the following template names. Place files with these names in your `templates_path` to override the defaults.
|
||||||
|
You do not need to override all templates, only the ones you want to customize should be present.
|
||||||
|
|
||||||
|
- Text templates (`.gotpl`):
|
||||||
|
- `mail_with_link.gotpl`
|
||||||
|
- `mail_with_attachment.gotpl`
|
||||||
|
- HTML templates (`.gohtml`):
|
||||||
|
- `mail_with_link.gohtml`
|
||||||
|
- `mail_with_attachment.gohtml`
|
||||||
|
|
||||||
|
Both [text](https://pkg.go.dev/text/template) and [HTML templates](https://pkg.go.dev/html/template) are standard Go
|
||||||
|
templates and receive the following data fields, depending on the email type:
|
||||||
|
|
||||||
|
- Common fields:
|
||||||
|
- `PortalUrl` (string) - external URL of the Portal
|
||||||
|
- `PortalName` (string) - site title/company name
|
||||||
|
- `User` (*domain.User) - the recipient user (may be partially populated when sending to a peer email)
|
||||||
|
- Link email (`mail_with_link.*`):
|
||||||
|
- `Link` (string) - the download link
|
||||||
|
- Attachment email (`mail_with_attachment.*`):
|
||||||
|
- `ConfigFileName` (string) - filename of the attached WireGuard config
|
||||||
|
- `QrcodePngName` (string) - CID content-id of the embedded QR code image
|
||||||
|
|
||||||
|
Tip: You can inspect the embedded templates in the repository under [`internal/app/mail/tpl_files/`](https://github.com/h44z/wg-portal/tree/master/internal/app/mail/tpl_files) for reference.
|
||||||
|
When the directory at `templates_path` is empty, these files are copied to your folder so you can edit them in place.
|
||||||
@@ -1,153 +1,12 @@
|
|||||||
This section describes the security features available to administrators for hardening WireGuard Portal and protecting its data.
|
This section describes the security features available to administrators for hardening WireGuard Portal and protecting its data.
|
||||||
|
|
||||||
## Authentication
|
## Database Encryption
|
||||||
|
|
||||||
WireGuard Portal supports multiple authentication methods, including:
|
WireGuard Portal supports multiple database backends. To reduce the risk of data exposure, sensitive information stored in the database can be encrypted.
|
||||||
|
To enable encryption, set the [`encryption_passphrase`](../configuration/overview.md#database) in the database configuration section.
|
||||||
- Local user accounts
|
|
||||||
- LDAP authentication
|
|
||||||
- OAuth and OIDC authentication
|
|
||||||
- Passkey authentication (WebAuthn)
|
|
||||||
|
|
||||||
Users can have two roles which limit their permissions in WireGuard Portal:
|
|
||||||
|
|
||||||
- **User**: Can manage their own account and peers.
|
|
||||||
- **Admin**: Can manage all users and peers, including the ability to manage WireGuard interfaces.
|
|
||||||
|
|
||||||
### Password Security
|
|
||||||
|
|
||||||
WireGuard Portal supports username and password authentication for both local and LDAP-backed accounts.
|
|
||||||
Local users are stored in the database, while LDAP users are authenticated against an external LDAP server.
|
|
||||||
|
|
||||||
On initial startup, WireGuard Portal automatically creates a local admin account with the password `wgportal-default`.
|
|
||||||
> :warning: This password must be changed immediately after the first login.
|
|
||||||
|
|
||||||
The minimum password length for all local users can be configured in the [`auth`](../configuration/overview.md#auth)
|
|
||||||
section of the configuration file. The default value is **16** characters, see [`min_password_length`](../configuration/overview.md#min_password_length).
|
|
||||||
The minimum password length is also enforced for the default admin user.
|
|
||||||
|
|
||||||
|
|
||||||
### Passkey (WebAuthn) Authentication
|
|
||||||
|
|
||||||
Besides the standard authentication mechanisms, WireGuard Portal supports Passkey authentication.
|
|
||||||
This feature is enabled by default and can be configured in the [`webauthn`](../configuration/overview.md#webauthn-passkeys) section of the configuration file.
|
|
||||||
|
|
||||||
Users can register multiple Passkeys to their account. These Passkeys can be used to log in to the web UI as long as the user is not locked.
|
|
||||||
> :warning: Passkey authentication does not disable password authentication. The password can still be used to log in (e.g., as a fallback).
|
|
||||||
|
|
||||||
To register a Passkey, open the settings page *(1)* in the web UI and click on the "Register Passkey" *(2)* button.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
### OAuth and OIDC Authentication
|
|
||||||
|
|
||||||
WireGuard Portal supports OAuth and OIDC authentication. You can use any OAuth or OIDC provider that supports the authorization code flow,
|
|
||||||
such as Google, GitHub, or Keycloak.
|
|
||||||
|
|
||||||
For OAuth or OIDC to work, you need to configure the [`external_url`](../configuration/overview.md#external_url) property in the [`web`](../configuration/overview.md#web) section of the configuration file.
|
|
||||||
If you are planning to expose the portal to the internet, make sure that the `external_url` is configured to use HTTPS.
|
|
||||||
|
|
||||||
To add OIDC or OAuth authentication to WireGuard Portal, create a Client-ID and Client-Secret in your OAuth provider and
|
|
||||||
configure a new authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
|
|
||||||
Make sure that each configured provider has a unique `provider_name` property set. Samples can be seen [here](../configuration/examples.md).
|
|
||||||
|
|
||||||
#### Limiting Login to Specific Domains
|
|
||||||
|
|
||||||
You can limit the login to specific domains by setting the `allowed_domains` property for OAuth or OIDC providers.
|
|
||||||
This property is a comma-separated list of domains that are allowed to log in. The user's email address is checked against this list.
|
|
||||||
For example, if you want to allow only users with an email address ending in `outlook.com` to log in, set the property as follows:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
auth:
|
|
||||||
oidc:
|
|
||||||
- provider_name: "oidc1"
|
|
||||||
# ... other settings
|
|
||||||
allowed_domains:
|
|
||||||
- "outlook.com"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Limit Login to Existing Users
|
|
||||||
|
|
||||||
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for OAuth or OIDC providers.
|
|
||||||
If registration is enabled, new users will be created in the database when they log in for the first time.
|
|
||||||
|
|
||||||
#### Admin Mapping
|
|
||||||
|
|
||||||
You can map users to admin roles based on their attributes in the OAuth or OIDC provider. To do this, set the `admin_mapping` property for the provider.
|
|
||||||
Administrative access can either be mapped by a specific attribute or by group membership.
|
|
||||||
|
|
||||||
**Attribute specific mapping** can be achieved by setting the `admin_value_regex` and the `is_admin` property.
|
|
||||||
The `admin_value_regex` property is a regular expression that is matched against the value of the `is_admin` attribute.
|
|
||||||
The user is granted admin access if the regex matches the attribute value.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```yaml
|
|
||||||
auth:
|
|
||||||
oidc:
|
|
||||||
- provider_name: "oidc1"
|
|
||||||
# ... other settings
|
|
||||||
field_map:
|
|
||||||
is_admin: "wg_admin_prop"
|
|
||||||
admin_mapping:
|
|
||||||
admin_value_regex: "^true$"
|
|
||||||
```
|
|
||||||
The example above will grant admin access to users with the `wg_admin_prop` attribute set to `true`.
|
|
||||||
|
|
||||||
**Group membership mapping** can be achieved by setting the `admin_group_regex` and `user_groups` property.
|
|
||||||
The `admin_group_regex` property is a regular expression that is matched against the group names of the user.
|
|
||||||
The user is granted admin access if the regex matches any of the group names.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```yaml
|
|
||||||
auth:
|
|
||||||
oidc:
|
|
||||||
- provider_name: "oidc1"
|
|
||||||
# ... other settings
|
|
||||||
field_map:
|
|
||||||
user_groups: "groups"
|
|
||||||
admin_mapping:
|
|
||||||
admin_group_regex: "^the-admin-group$"
|
|
||||||
```
|
|
||||||
The example above will grant admin access to users who are members of the `the-admin-group` group.
|
|
||||||
|
|
||||||
|
|
||||||
### LDAP Authentication
|
|
||||||
|
|
||||||
WireGuard Portal supports LDAP authentication. You can use any LDAP server that supports the LDAP protocol, such as Active Directory or OpenLDAP.
|
|
||||||
Multiple LDAP servers can be configured in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
|
|
||||||
WireGuard Portal remembers the authentication provider of the user and therefore avoids conflicts between multiple LDAP providers.
|
|
||||||
|
|
||||||
To configure LDAP authentication, create a new [`ldap`](../configuration/overview.md#ldap) authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
|
|
||||||
|
|
||||||
#### Limiting Login to Specific Users
|
|
||||||
|
|
||||||
You can limit the login to specific users by setting the `login_filter` property for LDAP provider. This filter uses the LDAP search filter syntax.
|
|
||||||
The username can be inserted into the query by placing the `{{login_identifier}}` placeholder in the filter. This placeholder will then be replaced with the username entered by the user during login.
|
|
||||||
|
|
||||||
For example, if you want to allow only users with the `objectClass` attribute set to `organizationalPerson` to log in, set the property as follows:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
auth:
|
|
||||||
ldap:
|
|
||||||
- provider_name: "ldap1"
|
|
||||||
# ... other settings
|
|
||||||
login_filter: "(&(objectClass=organizationalPerson)(uid={{login_identifier}}))"
|
|
||||||
```
|
|
||||||
|
|
||||||
The `login_filter` should always be designed to return at most one user.
|
|
||||||
|
|
||||||
#### Limit Login to Existing Users
|
|
||||||
|
|
||||||
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for LDAP providers.
|
|
||||||
If registration is enabled, new users will be created in the database when they log in for the first time.
|
|
||||||
|
|
||||||
#### Admin Mapping
|
|
||||||
|
|
||||||
You can map users to admin roles based on their group membership in the LDAP server. To do this, set the `admin_group` and `memberof` property for the provider.
|
|
||||||
The `admin_group` property defines the distinguished name of the group that is allowed to log in as admin.
|
|
||||||
All groups that are listed in the `memberof` attribute of the user will be checked against this group. If one of the groups matches, the user is granted admin access.
|
|
||||||
|
|
||||||
|
> :warning: Important: Once encryption is enabled, it cannot be disabled, and the passphrase cannot be changed!
|
||||||
|
> Only new or updated records will be encrypted; existing data remains in plaintext until it’s next modified.
|
||||||
|
|
||||||
## UI and API Access
|
## UI and API Access
|
||||||
|
|
||||||
@@ -158,3 +17,8 @@ It is recommended to use HTTPS for all communication with the portal to prevent
|
|||||||
|
|
||||||
Event though, WireGuard Portal supports HTTPS out of the box, it is recommended to use a reverse proxy like Nginx or Traefik to handle SSL termination and other security features.
|
Event though, WireGuard Portal supports HTTPS out of the box, it is recommended to use a reverse proxy like Nginx or Traefik to handle SSL termination and other security features.
|
||||||
A detailed explanation is available in the [Reverse Proxy](../getting-started/reverse-proxy.md) section.
|
A detailed explanation is available in the [Reverse Proxy](../getting-started/reverse-proxy.md) section.
|
||||||
|
|
||||||
|
### Secure Authentication
|
||||||
|
To prevent unauthorized access, WireGuard Portal supports integrating with secure authentication providers such as LDAP, OAuth2, or Passkeys, see [Authentication](./authentication.md) for more details.
|
||||||
|
When possible, use centralized authentication and enforce multi-factor authentication (MFA) at the provider level for enhanced account security.
|
||||||
|
For local accounts, administrators should enforce strong password requirements.
|
||||||
46
docs/documentation/usage/user-sync.md
Normal file
46
docs/documentation/usage/user-sync.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
For all external authentication providers (LDAP, OIDC, OAuth2), WireGuard Portal can automatically create a local user record upon the user's first successful login.
|
||||||
|
This behavior is controlled by the `registration_enabled` setting in each authentication provider's configuration.
|
||||||
|
|
||||||
|
User information from external authentication sources is merged into the corresponding local WireGuard Portal user record whenever the user logs in.
|
||||||
|
Additionally, WireGuard Portal supports periodic synchronization of user data from an LDAP directory.
|
||||||
|
|
||||||
|
To prevent overwriting local changes, WireGuard Portal allows you to set a per-user flag that disables synchronization of external attributes.
|
||||||
|
When this flag is set, the user in WireGuard Portal will not be updated automatically during log-ins or LDAP synchronization.
|
||||||
|
|
||||||
|
### LDAP Synchronization
|
||||||
|
|
||||||
|
WireGuard Portal lets you hook up any LDAP server such as Active Directory or OpenLDAP for both authentication and user sync.
|
||||||
|
You can even register multiple LDAP servers side-by-side. Details on the log-in process can be found in the [LDAP Authentication](./authentication.md#ldap-authentication) section.
|
||||||
|
|
||||||
|
If you enable LDAP synchronization, all users within the LDAP directory will be created automatically in the WireGuard Portal database if they do not exist.
|
||||||
|
If a user is disabled or deleted in LDAP, the user will be disabled in WireGuard Portal as well.
|
||||||
|
The synchronization process can be fine-tuned by multiple parameters, which are described below.
|
||||||
|
|
||||||
|
#### Synchronization Parameters
|
||||||
|
|
||||||
|
To enable the LDAP sycnhronization this feature, set the `sync_interval` property in the LDAP provider configuration to a value greater than "0".
|
||||||
|
The value is a string representing a duration, such as "15m" for 15 minutes or "1h" for 1 hour (check the [exact format definition](https://pkg.go.dev/time#ParseDuration) for details).
|
||||||
|
The synchronization process will run in the background and synchronize users from LDAP to the database at the specified interval.
|
||||||
|
Also make sure that the `sync_filter` property is a well-formed LDAP filter, or synchronization will fail.
|
||||||
|
|
||||||
|
##### Limiting Synchronization to Specific Users
|
||||||
|
|
||||||
|
Use the `sync_filter` property in your LDAP provider block to restrict which users get synchronized.
|
||||||
|
It accepts any valid LDAP search filter, only entries matching that filter will be pulled into the portal's database.
|
||||||
|
|
||||||
|
For example, to import only users with a `mail` attribute:
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
ldap:
|
||||||
|
- id: ldap
|
||||||
|
# ... other settings
|
||||||
|
sync_filter: (mail=*)
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Disable Missing Users
|
||||||
|
|
||||||
|
If you set the `disable_missing` property to `true`, any user that is not found in LDAP during synchronization will be disabled in WireGuard Portal.
|
||||||
|
All peers associated with that user will also be disabled.
|
||||||
|
|
||||||
|
If you want a user and its peers to be automatically re-enabled once they are found in LDAP again, set the `auto_re_enable` property to `true`.
|
||||||
|
This will only re-enable the user if they were disabled by the synchronization process. Manually disabled users will not be re-enabled.
|
||||||
@@ -68,26 +68,32 @@ All payload models are encoded as JSON objects. Fields with empty values might b
|
|||||||
|
|
||||||
#### User Payload (entity: `user`)
|
#### User Payload (entity: `user`)
|
||||||
|
|
||||||
| JSON Field | Type | Description |
|
| JSON Field | Type | Description |
|
||||||
|----------------|-------------|-----------------------------------|
|
|----------------|---------------|-----------------------------------|
|
||||||
| CreatedBy | string | Creator identifier |
|
| CreatedBy | string | Creator identifier |
|
||||||
| UpdatedBy | string | Last updater identifier |
|
| UpdatedBy | string | Last updater identifier |
|
||||||
| CreatedAt | time.Time | Time of creation |
|
| CreatedAt | time.Time | Time of creation |
|
||||||
| UpdatedAt | time.Time | Time of last update |
|
| UpdatedAt | time.Time | Time of last update |
|
||||||
| Identifier | string | Unique user identifier |
|
| Identifier | string | Unique user identifier |
|
||||||
| Email | string | User email |
|
| Email | string | User email |
|
||||||
| Source | string | Authentication source |
|
| AuthSources | []AuthSource | Authentication sources |
|
||||||
| ProviderName | string | Name of auth provider |
|
| IsAdmin | bool | Whether user has admin privileges |
|
||||||
| IsAdmin | bool | Whether user has admin privileges |
|
| Firstname | string | User's first name (optional) |
|
||||||
| Firstname | string | User's first name (optional) |
|
| Lastname | string | User's last name (optional) |
|
||||||
| Lastname | string | User's last name (optional) |
|
| Phone | string | Contact phone number (optional) |
|
||||||
| Phone | string | Contact phone number (optional) |
|
| Department | string | User's department (optional) |
|
||||||
| Department | string | User's department (optional) |
|
| Notes | string | Additional notes (optional) |
|
||||||
| Notes | string | Additional notes (optional) |
|
| Disabled | *time.Time | When user was disabled |
|
||||||
| Disabled | *time.Time | When user was disabled |
|
| DisabledReason | string | Reason for deactivation |
|
||||||
| DisabledReason | string | Reason for deactivation |
|
| Locked | *time.Time | When user account was locked |
|
||||||
| Locked | *time.Time | When user account was locked |
|
| LockedReason | string | Reason for being locked |
|
||||||
| LockedReason | string | Reason for being locked |
|
|
||||||
|
`AuthSource`:
|
||||||
|
|
||||||
|
| JSON Field | Type | Description |
|
||||||
|
|--------------|---------------|-----------------------------------------------------|
|
||||||
|
| Source | string | The authentication source (e.g. LDAP, OAuth, or DB) |
|
||||||
|
| ProviderName | string | The identifier of the authentication provider |
|
||||||
|
|
||||||
|
|
||||||
#### Peer Payload (entity: `peer`)
|
#### Peer Payload (entity: `peer`)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
}
|
}
|
||||||
.tx-hero__image {
|
.tx-hero__image {
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
min-width: 600px;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
|
|
||||||
.secondary-section .g .section .component-wrapper .responsive-grid .card {
|
.secondary-section .g .section .component-wrapper .responsive-grid .card {
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: #fff none repeat scroll 0% 0%;
|
background-color: #fff;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -363,7 +363,6 @@
|
|||||||
<h1>A beautiful and simple UI to manage your WireGuard peers and interfaces</h1>
|
<h1>A beautiful and simple UI to manage your WireGuard peers and interfaces</h1>
|
||||||
<p>WireGuard Portal is an open source web-based user interface that makes it easy to setup and manage
|
<p>WireGuard Portal is an open source web-based user interface that makes it easy to setup and manage
|
||||||
WireGuard VPN connections. It's built on top of WireGuard's official <span class="em">wgctrl</span> library.</p>
|
WireGuard VPN connections. It's built on top of WireGuard's official <span class="em">wgctrl</span> library.</p>
|
||||||
</p>
|
|
||||||
<a
|
<a
|
||||||
href="documentation/overview/"
|
href="documentation/overview/"
|
||||||
title="Get Started"
|
title="Get Started"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<script>
|
<script>
|
||||||
// global config, will be overridden by backend if available
|
// global config, will be overridden by backend if available
|
||||||
let WGPORTAL_BACKEND_BASE_URL="http://localhost:5000/api/v0";
|
let WGPORTAL_BACKEND_BASE_URL="http://localhost:5000/api/v0";
|
||||||
|
let WGPORTAL_BASE_PATH="";
|
||||||
let WGPORTAL_VERSION="unknown";
|
let WGPORTAL_VERSION="unknown";
|
||||||
let WGPORTAL_SITE_TITLE="WireGuard Portal";
|
let WGPORTAL_SITE_TITLE="WireGuard Portal";
|
||||||
let WGPORTAL_SITE_COMPANY_NAME="WireGuard Portal";
|
let WGPORTAL_SITE_COMPANY_NAME="WireGuard Portal";
|
||||||
|
|||||||
514
frontend/package-lock.json
generated
514
frontend/package-lock.json
generated
@@ -8,29 +8,30 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/nunito-sans": "^5.2.5",
|
"@fontsource/nunito-sans": "^5.2.7",
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||||
"@kyvg/vue3-notification": "^3.4.1",
|
"@kyvg/vue3-notification": "^3.4.2",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simplewebauthn/browser": "^13.1.0",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
"@vojtechlanka/vue-tags-input": "^3.1.1",
|
"@vojtechlanka/vue-tags-input": "^3.1.1",
|
||||||
"bootstrap": "^5.3.7",
|
"bootstrap": "^5.3.8",
|
||||||
"bootswatch": "^5.3.7",
|
"bootswatch": "^5.3.8",
|
||||||
"flag-icons": "^7.3.2",
|
"cidr-tools": "^11.0.3",
|
||||||
"ip-address": "^10.0.1",
|
"flag-icons": "^7.5.0",
|
||||||
"is-cidr": "^5.1.1",
|
"ip-address": "^10.1.0",
|
||||||
|
"is-cidr": "^6.0.1",
|
||||||
"is-ip": "^5.0.1",
|
"is-ip": "^5.0.1",
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.4",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.25",
|
||||||
"vue-i18n": "^11.1.3",
|
"vue-i18n": "^11.2.2",
|
||||||
"vue-prism-component": "github:h44z/vue-prism-component",
|
"vue-prism-component": "github:h44z/vue-prism-component",
|
||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
"sass-embedded": "^1.86.3",
|
"sass-embedded": "^1.93.3",
|
||||||
"vite": "^6.3.6"
|
"vite": "^7.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
@@ -43,21 +44,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.27.1",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.28.4",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||||
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
|
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.28.4"
|
"@babel/types": "^7.28.5"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
@@ -67,13 +68,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.28.4",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||||
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
|
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.27.1",
|
||||||
"@babel/helper-validator-identifier": "^7.27.1"
|
"@babel/helper-validator-identifier": "^7.28.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -538,22 +539,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fortawesome/fontawesome-free": {
|
"node_modules/@fortawesome/fontawesome-free": {
|
||||||
"version": "6.7.2",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.1.0.tgz",
|
||||||
"integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==",
|
"integrity": "sha512-+WxNld5ZCJHvPQCr/GnzCTVREyStrAJjisUPtUxG5ngDA8TMlPnKp6dddlTpai4+1GNmltAeuk1hJEkBohwZYA==",
|
||||||
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
|
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/core-base": {
|
"node_modules/@intlify/core-base": {
|
||||||
"version": "11.1.12",
|
"version": "11.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.2.tgz",
|
||||||
"integrity": "sha512-whh0trqRsSqVLNEUCwU59pyJZYpU8AmSWl8M3Jz2Mv5ESPP6kFh4juas2NpZ1iCvy7GlNRffUD1xr84gceimjg==",
|
"integrity": "sha512-0mCTBOLKIqFUP3BzwuFW23hYEl9g/wby6uY//AC5hTgQfTsM2srCYF2/hYGp+a5DZ/HIFIgKkLJMzXTt30r0JQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/message-compiler": "11.1.12",
|
"@intlify/message-compiler": "11.2.2",
|
||||||
"@intlify/shared": "11.1.12"
|
"@intlify/shared": "11.2.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
@@ -563,12 +564,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/message-compiler": {
|
"node_modules/@intlify/message-compiler": {
|
||||||
"version": "11.1.12",
|
"version": "11.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.2.tgz",
|
||||||
"integrity": "sha512-Fv9iQSJoJaXl4ZGkOCN1LDM3trzze0AS2zRz2EHLiwenwL6t0Ki9KySYlyr27yVOj5aVz0e55JePO+kELIvfdQ==",
|
"integrity": "sha512-XS2p8Ff5JxWsKhgfld4/MRQzZRQ85drMMPhb7Co6Be4ZOgqJX1DzcZt0IFgGTycgqL8rkYNwgnD443Q+TapOoA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/shared": "11.1.12",
|
"@intlify/shared": "11.2.2",
|
||||||
"source-map-js": "^1.0.2"
|
"source-map-js": "^1.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -579,9 +580,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/shared": {
|
"node_modules/@intlify/shared": {
|
||||||
"version": "11.1.12",
|
"version": "11.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.2.tgz",
|
||||||
"integrity": "sha512-Om86EjuQtA69hdNj3GQec9ZC0L0vPSAnXzB3gP/gyJ7+mA7t06d9aOAiqMZ+xEOsumGP4eEBlfl8zF2LOTzf2A==",
|
"integrity": "sha512-OtCmyFpSXxNu/oET/aN6HtPCbZ01btXVd0f3w00YsHOb13Kverk1jzA2k47pAekM55qbUw421fvPF1yxZ+gicw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
@@ -597,9 +598,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@kyvg/vue3-notification": {
|
"node_modules/@kyvg/vue3-notification": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@kyvg/vue3-notification/-/vue3-notification-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@kyvg/vue3-notification/-/vue3-notification-3.4.2.tgz",
|
||||||
"integrity": "sha512-WhTWCbF36JHLJR5UdKmJF7KXGOGVy4tLeaJuKTHZhwttZWnbF9w1/c2d32tvCSwY9CdeX/n9uoaKWLMKK3vOyg==",
|
"integrity": "sha512-CZ2zOdXsbGCtWbdqMgbusKtZTkMT+dYpw9bmAitsdSNHT0knh4njD8X95JIyTMWvNVjhDkFedbkNiZLcPqttwQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "^3.0.0"
|
"vue": "^3.0.0"
|
||||||
@@ -920,12 +921,18 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/popperjs"
|
"url": "https://opencollective.com/popperjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rolldown/pluginutils": {
|
||||||
|
"version": "1.0.0-beta.50",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz",
|
||||||
|
"integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.52.4",
|
"version": "4.52.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz",
|
||||||
@@ -1235,9 +1242,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@simplewebauthn/browser": {
|
"node_modules/@simplewebauthn/browser": {
|
||||||
"version": "13.2.0",
|
"version": "13.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
|
||||||
"integrity": "sha512-N3fuA1AAnTo5gCStYoIoiasPccC+xPLx2YU88Dv0GeAmPQTWHETlZQq5xZ0DgUq1H9loXMWQH5qqUjcI7BHJ1A==",
|
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
@@ -1248,16 +1255,19 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "5.2.4",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.2.tgz",
|
||||||
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
|
"integrity": "sha512-iHmwV3QcVGGvSC1BG5bZ4z6iwa1SOpAPWmnjOErd4Ske+lZua5K9TtAVdx0gMBClJ28DViCbSmZitjWZsWO3LA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rolldown/pluginutils": "1.0.0-beta.50"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.0.0 || >=20.0.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vite": "^5.0.0 || ^6.0.0",
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
||||||
"vue": "^3.2.25"
|
"vue": "^3.2.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1276,53 +1286,53 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-core": {
|
"node_modules/@vue/compiler-core": {
|
||||||
"version": "3.5.22",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
|
||||||
"integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==",
|
"integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.4",
|
"@babel/parser": "^7.28.5",
|
||||||
"@vue/shared": "3.5.22",
|
"@vue/shared": "3.5.25",
|
||||||
"entities": "^4.5.0",
|
"entities": "^4.5.0",
|
||||||
"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.22",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz",
|
||||||
"integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==",
|
"integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-core": "3.5.22",
|
"@vue/compiler-core": "3.5.25",
|
||||||
"@vue/shared": "3.5.22"
|
"@vue/shared": "3.5.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-sfc": {
|
"node_modules/@vue/compiler-sfc": {
|
||||||
"version": "3.5.22",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz",
|
||||||
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
|
"integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.4",
|
"@babel/parser": "^7.28.5",
|
||||||
"@vue/compiler-core": "3.5.22",
|
"@vue/compiler-core": "3.5.25",
|
||||||
"@vue/compiler-dom": "3.5.22",
|
"@vue/compiler-dom": "3.5.25",
|
||||||
"@vue/compiler-ssr": "3.5.22",
|
"@vue/compiler-ssr": "3.5.25",
|
||||||
"@vue/shared": "3.5.22",
|
"@vue/shared": "3.5.25",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"magic-string": "^0.30.19",
|
"magic-string": "^0.30.21",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-ssr": {
|
"node_modules/@vue/compiler-ssr": {
|
||||||
"version": "3.5.22",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz",
|
||||||
"integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==",
|
"integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.22",
|
"@vue/compiler-dom": "3.5.25",
|
||||||
"@vue/shared": "3.5.22"
|
"@vue/shared": "3.5.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/devtools-api": {
|
"node_modules/@vue/devtools-api": {
|
||||||
@@ -1359,53 +1369,53 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/reactivity": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.5.22",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz",
|
||||||
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
|
"integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/shared": "3.5.22"
|
"@vue/shared": "3.5.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-core": {
|
"node_modules/@vue/runtime-core": {
|
||||||
"version": "3.5.22",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz",
|
||||||
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
|
"integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.22",
|
"@vue/reactivity": "3.5.25",
|
||||||
"@vue/shared": "3.5.22"
|
"@vue/shared": "3.5.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-dom": {
|
"node_modules/@vue/runtime-dom": {
|
||||||
"version": "3.5.22",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz",
|
||||||
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
|
"integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.22",
|
"@vue/reactivity": "3.5.25",
|
||||||
"@vue/runtime-core": "3.5.22",
|
"@vue/runtime-core": "3.5.25",
|
||||||
"@vue/shared": "3.5.22",
|
"@vue/shared": "3.5.25",
|
||||||
"csstype": "^3.1.3"
|
"csstype": "^3.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/server-renderer": {
|
"node_modules/@vue/server-renderer": {
|
||||||
"version": "3.5.22",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz",
|
||||||
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
|
"integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-ssr": "3.5.22",
|
"@vue/compiler-ssr": "3.5.25",
|
||||||
"@vue/shared": "3.5.22"
|
"@vue/shared": "3.5.25"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "3.5.22"
|
"vue": "3.5.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/shared": {
|
"node_modules/@vue/shared": {
|
||||||
"version": "3.5.22",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz",
|
||||||
"integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==",
|
"integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/birpc": {
|
"node_modules/birpc": {
|
||||||
@@ -1481,15 +1491,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cidr-regex": {
|
"node_modules/cidr-regex": {
|
||||||
"version": "4.1.3",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cidr-regex/-/cidr-regex-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/cidr-regex/-/cidr-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-86M1y3ZeQvpZkZejQCcS+IaSWjlDUC+ORP0peScQ4uEUFCZ8bEQVz7NlJHqysoUb6w3zCjx4Mq/8/2RHhMwHYw==",
|
"integrity": "sha512-2Apfc6qH9uwF3QHmlYBA8ExB9VHq+1/Doj9sEMY55TVBcpQ3y/+gmMpcNIBBtfb5k54Vphmta+1IxjMqPlWWAA==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-regex": "^5.0.0"
|
"ip-regex": "5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cidr-tools": {
|
||||||
|
"version": "11.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/cidr-tools/-/cidr-tools-11.0.3.tgz",
|
||||||
|
"integrity": "sha512-7p0rp7B2P+nZfBkJlrQzUMDyUHeYK2h/XCJY80VUl1v5oxwLxQjZMy39BXVOXugwAX67l0oJ/QQ6OhANgUtUbw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"ip-bigint": "^8.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/clone-regexp": {
|
"node_modules/clone-regexp": {
|
||||||
@@ -1542,9 +1564,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.1.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
@@ -1698,14 +1720,23 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.0.1",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-bigint": {
|
||||||
|
"version": "8.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-bigint/-/ip-bigint-8.2.2.tgz",
|
||||||
|
"integrity": "sha512-wPoOpHigOtoY29UCFA0L82cJVFcT7M+TsrgipUVpFw7HV9LpLEuNXCymt3623jzHPlIZzFaCyaVf9VACssFYew==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ip-regex": {
|
"node_modules/ip-regex": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz",
|
||||||
@@ -1719,15 +1750,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-cidr": {
|
"node_modules/is-cidr": {
|
||||||
"version": "5.1.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-cidr/-/is-cidr-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-cidr/-/is-cidr-6.0.1.tgz",
|
||||||
"integrity": "sha512-AwzRMjtJNTPOgm7xuYZ71715z99t+4yRnSnSzgK5err5+heYi4zMuvmpUadaJ28+KCXCQo8CjUrKQZRWSPmqTQ==",
|
"integrity": "sha512-JIJlvXodfsoWFAvvjB7Elqu8qQcys2SZjkIJCLdk4XherUqZ6+zH7WIpXkp4B3ZxMH0Fz7zIsZwyvs6JfM0csw==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cidr-regex": "^4.1.1"
|
"cidr-regex": "5.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
@@ -1807,9 +1838,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.19",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
@@ -1889,19 +1920,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pinia": {
|
"node_modules/pinia": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||||
"integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==",
|
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-api": "^7.7.2"
|
"@vue/devtools-api": "^7.7.7"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/posva"
|
"url": "https://github.com/sponsors/posva"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=4.4.4",
|
"typescript": ">=4.5.0",
|
||||||
"vue": "^2.7.0 || ^3.5.11"
|
"vue": "^3.5.11"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"typescript": {
|
"typescript": {
|
||||||
@@ -2020,9 +2051,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass": {
|
"node_modules/sass": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.3.tgz",
|
||||||
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
|
"integrity": "sha512-elOcIZRTM76dvxNAjqYrucTSI0teAF/L2Lv0s6f6b7FOwcwIuA357bIE871580AjHJuSvLIRUosgV+lIWx6Rgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -2042,12 +2073,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded": {
|
"node_modules/sass-embedded": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.3.tgz",
|
||||||
"integrity": "sha512-FvQdkn2dZ8DGiLgi0Uf4zsj7r/BsiLImNa5QJ10eZalY6NfZyjrmWGFcuCN5jNwlDlXFJnftauv+UtvBKLvepQ==",
|
"integrity": "sha512-+VUy01yfDqNmIVMd/LLKl2TTtY0ovZN0rTonh+FhKr65mFwIYgU9WzgIZKS7U9/SPCQvWTsTGx9jyt+qRm/XFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^2.5.0",
|
"@bufbuild/protobuf": "^2.5.0",
|
||||||
"buffer-builder": "^0.2.0",
|
"buffer-builder": "^0.2.0",
|
||||||
@@ -2065,30 +2095,30 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"sass-embedded-all-unknown": "1.93.2",
|
"sass-embedded-all-unknown": "1.93.3",
|
||||||
"sass-embedded-android-arm": "1.93.2",
|
"sass-embedded-android-arm": "1.93.3",
|
||||||
"sass-embedded-android-arm64": "1.93.2",
|
"sass-embedded-android-arm64": "1.93.3",
|
||||||
"sass-embedded-android-riscv64": "1.93.2",
|
"sass-embedded-android-riscv64": "1.93.3",
|
||||||
"sass-embedded-android-x64": "1.93.2",
|
"sass-embedded-android-x64": "1.93.3",
|
||||||
"sass-embedded-darwin-arm64": "1.93.2",
|
"sass-embedded-darwin-arm64": "1.93.3",
|
||||||
"sass-embedded-darwin-x64": "1.93.2",
|
"sass-embedded-darwin-x64": "1.93.3",
|
||||||
"sass-embedded-linux-arm": "1.93.2",
|
"sass-embedded-linux-arm": "1.93.3",
|
||||||
"sass-embedded-linux-arm64": "1.93.2",
|
"sass-embedded-linux-arm64": "1.93.3",
|
||||||
"sass-embedded-linux-musl-arm": "1.93.2",
|
"sass-embedded-linux-musl-arm": "1.93.3",
|
||||||
"sass-embedded-linux-musl-arm64": "1.93.2",
|
"sass-embedded-linux-musl-arm64": "1.93.3",
|
||||||
"sass-embedded-linux-musl-riscv64": "1.93.2",
|
"sass-embedded-linux-musl-riscv64": "1.93.3",
|
||||||
"sass-embedded-linux-musl-x64": "1.93.2",
|
"sass-embedded-linux-musl-x64": "1.93.3",
|
||||||
"sass-embedded-linux-riscv64": "1.93.2",
|
"sass-embedded-linux-riscv64": "1.93.3",
|
||||||
"sass-embedded-linux-x64": "1.93.2",
|
"sass-embedded-linux-x64": "1.93.3",
|
||||||
"sass-embedded-unknown-all": "1.93.2",
|
"sass-embedded-unknown-all": "1.93.3",
|
||||||
"sass-embedded-win32-arm64": "1.93.2",
|
"sass-embedded-win32-arm64": "1.93.3",
|
||||||
"sass-embedded-win32-x64": "1.93.2"
|
"sass-embedded-win32-x64": "1.93.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-all-unknown": {
|
"node_modules/sass-embedded-all-unknown": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.3.tgz",
|
||||||
"integrity": "sha512-GdEuPXIzmhRS5J7UKAwEvtk8YyHQuFZRcpnEnkA3rwRUI27kwjyXkNeIj38XjUQ3DzrfMe8HcKFaqWGHvblS7Q==",
|
"integrity": "sha512-3okGgnE41eg+CPLtAPletu6nQ4N0ij7AeW+Sl5Km4j29XcmqZQeFwYjHe1AlKTEgLi/UAONk1O8i8/lupeKMbw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"!arm",
|
"!arm",
|
||||||
"!arm64",
|
"!arm64",
|
||||||
@@ -2099,13 +2129,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sass": "1.93.2"
|
"sass": "1.93.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-android-arm": {
|
"node_modules/sass-embedded-android-arm": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.3.tgz",
|
||||||
"integrity": "sha512-I8bpO8meZNo5FvFx5FIiE7DGPVOYft0WjuwcCCdeJ6duwfkl6tZdatex1GrSigvTsuz9L0m4ngDcX/Tj/8yMow==",
|
"integrity": "sha512-8xOw9bywfOD6Wv24BgCmgjkk6tMrsOTTHcb28KDxeJtFtoxiUyMbxo0vChpPAfp2Hyg2tFFKS60s0s4JYk+Raw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2120,9 +2150,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-android-arm64": {
|
"node_modules/sass-embedded-android-arm64": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.3.tgz",
|
||||||
"integrity": "sha512-346f4iVGAPGcNP6V6IOOFkN5qnArAoXNTPr5eA/rmNpeGwomdb7kJyQ717r9rbJXxOG8OAAUado6J0qLsjnjXQ==",
|
"integrity": "sha512-uqUl3Kt1IqdGVAcAdbmC+NwuUJy8tM+2ZnB7/zrt6WxWVShVCRdFnWR9LT8HJr7eJN7AU8kSXxaVX/gedanPsg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2137,9 +2167,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-android-riscv64": {
|
"node_modules/sass-embedded-android-riscv64": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.3.tgz",
|
||||||
"integrity": "sha512-hSMW1s4yJf5guT9mrdkumluqrwh7BjbZ4MbBW9tmi1DRDdlw1Wh9Oy1HnnmOG8x9XcI1qkojtPL6LUuEJmsiDg==",
|
"integrity": "sha512-2jNJDmo+3qLocjWqYbXiBDnfgwrUeZgZFHJIwAefU7Fn66Ot7rsXl+XPwlokaCbTpj7eMFIqsRAZ/uDueXNCJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2154,9 +2184,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-android-x64": {
|
"node_modules/sass-embedded-android-x64": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.3.tgz",
|
||||||
"integrity": "sha512-JqktiHZduvn+ldGBosE40ALgQ//tGCVNAObgcQ6UIZznEJbsHegqStqhRo8UW3x2cgOO2XYJcrInH6cc7wdKbw==",
|
"integrity": "sha512-y0RoAU6ZenQFcjM9PjQd3cRqRTjqwSbtWLL/p68y2oFyh0QGN0+LQ826fc0ZvU/AbqCsAizkqjzOn6cRZJxTTQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2171,9 +2201,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-darwin-arm64": {
|
"node_modules/sass-embedded-darwin-arm64": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.3.tgz",
|
||||||
"integrity": "sha512-qI1X16qKNeBJp+M/5BNW7v/JHCDYWr1/mdoJ7+UMHmP0b5AVudIZtimtK0hnjrLnBECURifd6IkulybR+h+4UA==",
|
"integrity": "sha512-7zb/hpdMOdKteK17BOyyypemglVURd1Hdz6QGsggy60aUFfptTLQftLRg8r/xh1RbQAUKWFbYTNaM47J9yPxYg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2188,9 +2218,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-darwin-x64": {
|
"node_modules/sass-embedded-darwin-x64": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.3.tgz",
|
||||||
"integrity": "sha512-4KeAvlkQ0m0enKUnDGQJZwpovYw99iiMb8CTZRSsQm8Eh7halbJZVmx67f4heFY/zISgVOCcxNg19GrM5NTwtA==",
|
"integrity": "sha512-Ek1Vp8ZDQEe327Lz0b7h3hjvWH3u9XjJiQzveq74RPpJQ2q6d9LfWpjiRRohM4qK6o4XOHw1X10OMWPXJtdtWg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2205,9 +2235,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-arm": {
|
"node_modules/sass-embedded-linux-arm": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.3.tgz",
|
||||||
"integrity": "sha512-N3+D/ToHtzwLDO+lSH05Wo6/KRxFBPnbjVHASOlHzqJnK+g5cqex7IFAp6ozzlRStySk61Rp6d+YGrqZ6/P0PA==",
|
"integrity": "sha512-yeiv2y+dp8B4wNpd3+JsHYD0mvpXSfov7IGyQ1tMIR40qv+ROkRqYiqQvAOXf76Qwh4Y9OaYZtLpnsPjfeq6mA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2222,9 +2252,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-arm64": {
|
"node_modules/sass-embedded-linux-arm64": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.3.tgz",
|
||||||
"integrity": "sha512-9ftX6nd5CsShJqJ2WRg+ptaYvUW+spqZfJ88FbcKQBNFQm6L87luj3UI1rB6cP5EWrLwHA754OKxRJyzWiaN6g==",
|
"integrity": "sha512-RBrHWgfd8Dd8w4fbmdRVXRrhh8oBAPyeWDTKAWw8ZEmuXfVl4ytjDuyxaVilh6rR1xTRTNpbaA/YWApBlLrrNw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2239,9 +2269,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-musl-arm": {
|
"node_modules/sass-embedded-linux-musl-arm": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.3.tgz",
|
||||||
"integrity": "sha512-XBTvx66yRenvEsp3VaJCb3HQSyqCsUh7R+pbxcN5TuzueybZi0LXvn9zneksdXcmjACMlMpIVXi6LyHPQkYc8A==",
|
"integrity": "sha512-fU0fwAwbp7sBE3h5DVU5UPzvaLg7a4yONfFWkkcCp6ZrOiPuGRHXXYriWQ0TUnWy4wE+svsVuWhwWgvlb/tkKg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2256,9 +2286,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-musl-arm64": {
|
"node_modules/sass-embedded-linux-musl-arm64": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.3.tgz",
|
||||||
"integrity": "sha512-+3EHuDPkMiAX5kytsjEC1bKZCawB9J6pm2eBIzzLMPWbf5xdx++vO1DpT7hD4bm4ZGn0eVHgSOKIfP6CVz6tVg==",
|
"integrity": "sha512-PS829l+eUng+9W4PFclXGb4uA2+965NHV3/Sa5U7qTywjeeUUYTZg70dJHSqvhrBEfCc2XJABeW3adLJbyQYkw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2273,9 +2303,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-musl-riscv64": {
|
"node_modules/sass-embedded-linux-musl-riscv64": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.3.tgz",
|
||||||
"integrity": "sha512-0sB5kmVZDKTYzmCSlTUnjh6mzOhzmQiW/NNI5g8JS4JiHw2sDNTvt1dsFTuqFkUHyEOY3ESTsfHHBQV8Ip4bEA==",
|
"integrity": "sha512-cK1oBY+FWQquaIGEeQ5H74KTO8cWsSWwXb/WaildOO9U6wmUypTgUYKQ0o5o/29nZbWWlM1PHuwVYTSnT23Jjg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2290,9 +2320,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-musl-x64": {
|
"node_modules/sass-embedded-linux-musl-x64": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.3.tgz",
|
||||||
"integrity": "sha512-t3ejQ+1LEVuHy7JHBI2tWHhoMfhedUNDjGJR2FKaLgrtJntGnyD1RyX0xb3nuqL/UXiEAtmTmZY+Uh3SLUe1Hg==",
|
"integrity": "sha512-A7wkrsHu2/I4Zpa0NMuPGkWDVV7QGGytxGyUq3opSXgAexHo/vBPlGoDXoRlSdex0cV+aTMRPjoGIfdmNlHwyg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2307,9 +2337,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-riscv64": {
|
"node_modules/sass-embedded-linux-riscv64": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.3.tgz",
|
||||||
"integrity": "sha512-e7AndEwAbFtXaLy6on4BfNGTr3wtGZQmypUgYpSNVcYDO+CWxatKVY4cxbehMPhxG9g5ru+eaMfynvhZt7fLaA==",
|
"integrity": "sha512-vWkW1+HTF5qcaHa6hO80gx/QfB6GGjJUP0xLbnAoY4pwEnw5ulGv6RM8qYr8IDhWfVt/KH+lhJ2ZFxnJareisQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2324,9 +2354,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-x64": {
|
"node_modules/sass-embedded-linux-x64": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.3.tgz",
|
||||||
"integrity": "sha512-U3EIUZQL11DU0xDDHXexd4PYPHQaSQa2hzc4EzmhHqrAj+TyfYO94htjWOd+DdTPtSwmLp+9cTWwPZBODzC96w==",
|
"integrity": "sha512-k6uFxs+e5jSuk1Y0niCwuq42F9ZC5UEP7P+RIOurIm8w/5QFa0+YqeW+BPWEW5M1FqVOsNZH3qGn4ahqvAEjPA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2341,9 +2371,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-unknown-all": {
|
"node_modules/sass-embedded-unknown-all": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.3.tgz",
|
||||||
"integrity": "sha512-7VnaOmyewcXohiuoFagJ3SK5ddP9yXpU0rzz+pZQmS1/+5O6vzyFCUoEt3HDRaLctH4GT3nUGoK1jg0ae62IfQ==",
|
"integrity": "sha512-o5wj2rLpXH0C+GJKt/VpWp6AnMsCCbfFmnMAttcrsa+U3yrs/guhZ3x55KAqqUsE8F47e3frbsDL+1OuQM5DAA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -2354,13 +2384,13 @@
|
|||||||
"!win32"
|
"!win32"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sass": "1.93.2"
|
"sass": "1.93.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-win32-arm64": {
|
"node_modules/sass-embedded-win32-arm64": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.3.tgz",
|
||||||
"integrity": "sha512-Y90DZDbQvtv4Bt0GTXKlcT9pn4pz8AObEjFF8eyul+/boXwyptPZ/A1EyziAeNaIEIfxyy87z78PUgCeGHsx3Q==",
|
"integrity": "sha512-0dOfT9moy9YmBolodwYYXtLwNr4jL4HQC9rBfv6mVrD7ud8ue2kDbn+GVzj1hEJxvEexVSmDCf7MHUTLcGs9xQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2375,9 +2405,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-win32-x64": {
|
"node_modules/sass-embedded-win32-x64": {
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.2.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.3.tgz",
|
||||||
"integrity": "sha512-BbSucRP6PVRZGIwlEBkp+6VQl2GWdkWFMN+9EuOTPrLxCJZoq+yhzmbjspd3PeM8+7WJ7AdFu/uRYdO8tor1iQ==",
|
"integrity": "sha512-wHFVfxiS9hU/sNk7KReD+lJWRp3R0SLQEX4zfOnRP2zlvI2X4IQR5aZr9GNcuMP6TmNpX0nQPZTegS8+h9RrEg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2539,7 +2569,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2576,25 +2605,24 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.3.6",
|
"version": "7.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
|
||||||
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
|
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.6",
|
||||||
"rollup": "^4.34.9",
|
"rollup": "^4.43.0",
|
||||||
"tinyglobby": "^0.2.13"
|
"tinyglobby": "^0.2.15"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||||
@@ -2603,14 +2631,14 @@
|
|||||||
"fsevents": "~2.3.3"
|
"fsevents": "~2.3.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "*",
|
"less": "^4.0.0",
|
||||||
"lightningcss": "^1.21.0",
|
"lightningcss": "^1.21.0",
|
||||||
"sass": "*",
|
"sass": "^1.70.0",
|
||||||
"sass-embedded": "*",
|
"sass-embedded": "^1.70.0",
|
||||||
"stylus": "*",
|
"stylus": ">=0.54.8",
|
||||||
"sugarss": "*",
|
"sugarss": "^5.0.0",
|
||||||
"terser": "^5.16.0",
|
"terser": "^5.16.0",
|
||||||
"tsx": "^4.8.1",
|
"tsx": "^4.8.1",
|
||||||
"yaml": "^2.4.2"
|
"yaml": "^2.4.2"
|
||||||
@@ -2675,7 +2703,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2684,17 +2711,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue": {
|
"node_modules/vue": {
|
||||||
"version": "3.5.22",
|
"version": "3.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
|
||||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.22",
|
"@vue/compiler-dom": "3.5.25",
|
||||||
"@vue/compiler-sfc": "3.5.22",
|
"@vue/compiler-sfc": "3.5.25",
|
||||||
"@vue/runtime-dom": "3.5.22",
|
"@vue/runtime-dom": "3.5.25",
|
||||||
"@vue/server-renderer": "3.5.22",
|
"@vue/server-renderer": "3.5.25",
|
||||||
"@vue/shared": "3.5.22"
|
"@vue/shared": "3.5.25"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "*"
|
"typescript": "*"
|
||||||
@@ -2706,13 +2732,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-i18n": {
|
"node_modules/vue-i18n": {
|
||||||
"version": "11.1.12",
|
"version": "11.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.2.tgz",
|
||||||
"integrity": "sha512-BnstPj3KLHLrsqbVU2UOrPmr0+Mv11bsUZG0PyCOzsawCivk8W00GMXHeVUWIDOgNaScCuZah47CZFE+Wnl8mw==",
|
"integrity": "sha512-ULIKZyRluUPRCZmihVgUvpq8hJTtOqnbGZuv4Lz+byEKZq4mU0g92og414l6f/4ju+L5mORsiUuEPYrAuX2NJg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/core-base": "11.1.12",
|
"@intlify/core-base": "11.2.2",
|
||||||
"@intlify/shared": "11.1.12",
|
"@intlify/shared": "11.2.2",
|
||||||
"@vue/devtools-api": "^6.5.0"
|
"@vue/devtools-api": "^6.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2737,9 +2763,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.5.1",
|
"version": "4.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",
|
||||||
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
|
"integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-api": "^6.6.4"
|
"@vue/devtools-api": "^6.6.4"
|
||||||
@@ -2748,7 +2774,7 @@
|
|||||||
"url": "https://github.com/sponsors/posva"
|
"url": "https://github.com/sponsors/posva"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "^3.2.0"
|
"vue": "^3.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-router/node_modules/@vue/devtools-api": {
|
"node_modules/vue-router/node_modules/@vue/devtools-api": {
|
||||||
|
|||||||
@@ -8,28 +8,29 @@
|
|||||||
"preview": "vite preview --port 5050"
|
"preview": "vite preview --port 5050"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/nunito-sans": "^5.2.5",
|
"@fontsource/nunito-sans": "^5.2.7",
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||||
"@kyvg/vue3-notification": "^3.4.1",
|
"@kyvg/vue3-notification": "^3.4.2",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simplewebauthn/browser": "^13.1.0",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
"@vojtechlanka/vue-tags-input": "^3.1.1",
|
"@vojtechlanka/vue-tags-input": "^3.1.1",
|
||||||
"bootstrap": "^5.3.7",
|
"bootstrap": "^5.3.8",
|
||||||
"bootswatch": "^5.3.7",
|
"bootswatch": "^5.3.8",
|
||||||
"flag-icons": "^7.3.2",
|
"cidr-tools": "^11.0.3",
|
||||||
"ip-address": "^10.0.1",
|
"flag-icons": "^7.5.0",
|
||||||
"is-cidr": "^5.1.1",
|
"ip-address": "^10.1.0",
|
||||||
|
"is-cidr": "^6.0.1",
|
||||||
"is-ip": "^5.0.1",
|
"is-ip": "^5.0.1",
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.4",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.25",
|
||||||
"vue-i18n": "^11.1.3",
|
"vue-i18n": "^11.2.2",
|
||||||
"vue-prism-component": "github:h44z/vue-prism-component",
|
"vue-prism-component": "github:h44z/vue-prism-component",
|
||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
"sass-embedded": "^1.86.3",
|
"sass-embedded": "^1.93.3",
|
||||||
"vite": "^6.3.6"
|
"vite": "^7.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const languageFlag = computed(() => {
|
|||||||
const companyName = ref(WGPORTAL_SITE_COMPANY_NAME);
|
const companyName = ref(WGPORTAL_SITE_COMPANY_NAME);
|
||||||
const wgVersion = ref(WGPORTAL_VERSION);
|
const wgVersion = ref(WGPORTAL_VERSION);
|
||||||
const currentYear = ref(new Date().getFullYear())
|
const currentYear = ref(new Date().getFullYear())
|
||||||
|
const webBasePath = ref(WGPORTAL_BASE_PATH);
|
||||||
|
|
||||||
const userDisplayName = computed(() => {
|
const userDisplayName = computed(() => {
|
||||||
let displayName = "Unknown";
|
let displayName = "Unknown";
|
||||||
@@ -113,7 +114,7 @@ const userDisplayName = computed(() => {
|
|||||||
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="/"><img :alt="companyName" src="/img/header-logo.png" /></a>
|
<RouterLink class="navbar-brand" :to="{ name: 'home' }"><img :alt="companyName" :src="webBasePath + '/img/header-logo.png'" /></RouterLink>
|
||||||
<button aria-controls="navbarColor01" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"
|
<button aria-controls="navbarColor01" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"
|
||||||
data-bs-target="#navbarTop" data-bs-toggle="collapse" type="button">
|
data-bs-target="#navbarTop" data-bs-toggle="collapse" type="button">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
@@ -133,6 +134,9 @@ const userDisplayName = computed(() => {
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<RouterLink :to="{ name: 'key-generator' }" class="nav-link">{{ $t('menu.keygen') }}</RouterLink>
|
<RouterLink :to="{ name: 'key-generator' }" class="nav-link">{{ $t('menu.keygen') }}</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<RouterLink :to="{ name: 'ip-calculator' }" class="nav-link">{{ $t('menu.calculator') }}</RouterLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="navbar-nav d-flex justify-content-end">
|
<div class="navbar-nav d-flex justify-content-end">
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
|
|||||||
formData.value.Identifier = interfaces.Prepared.Identifier
|
formData.value.Identifier = interfaces.Prepared.Identifier
|
||||||
formData.value.DisplayName = interfaces.Prepared.DisplayName
|
formData.value.DisplayName = interfaces.Prepared.DisplayName
|
||||||
formData.value.Mode = interfaces.Prepared.Mode
|
formData.value.Mode = interfaces.Prepared.Mode
|
||||||
|
formData.value.CreateDefaultPeer = interfaces.Prepared.CreateDefaultPeer
|
||||||
formData.value.Backend = interfaces.Prepared.Backend
|
formData.value.Backend = interfaces.Prepared.Backend
|
||||||
|
|
||||||
formData.value.PublicKey = interfaces.Prepared.PublicKey
|
formData.value.PublicKey = interfaces.Prepared.PublicKey
|
||||||
@@ -122,6 +123,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
|
|||||||
formData.value.Identifier = selectedInterface.value.Identifier
|
formData.value.Identifier = selectedInterface.value.Identifier
|
||||||
formData.value.DisplayName = selectedInterface.value.DisplayName
|
formData.value.DisplayName = selectedInterface.value.DisplayName
|
||||||
formData.value.Mode = selectedInterface.value.Mode
|
formData.value.Mode = selectedInterface.value.Mode
|
||||||
|
formData.value.CreateDefaultPeer = selectedInterface.value.CreateDefaultPeer
|
||||||
formData.value.Backend = selectedInterface.value.Backend
|
formData.value.Backend = selectedInterface.value.Backend
|
||||||
|
|
||||||
formData.value.PublicKey = selectedInterface.value.PublicKey
|
formData.value.PublicKey = selectedInterface.value.PublicKey
|
||||||
@@ -487,6 +489,10 @@ 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')">
|
||||||
|
<input v-model="formData.CreateDefaultPeer" class="form-check-input" type="checkbox">
|
||||||
|
<label class="form-check-label">{{ $t('modals.interface-edit.create-default-peer.label') }}</label>
|
||||||
|
</div>
|
||||||
<div class="form-check form-switch" v-if="formData.Backend==='local'">
|
<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>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const passwordWeak = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const formValid = computed(() => {
|
const formValid = computed(() => {
|
||||||
if (formData.value.Source !== 'db') {
|
if (!formData.value.AuthSources.some(s => s === 'db')) {
|
||||||
return true // nothing to validate
|
return true // nothing to validate
|
||||||
}
|
}
|
||||||
if (props.userId !== '#NEW#' && passwordWeak.value) {
|
if (props.userId !== '#NEW#' && passwordWeak.value) {
|
||||||
@@ -70,7 +70,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
|
|||||||
} else { // fill existing userdata
|
} else { // fill existing userdata
|
||||||
formData.value.Identifier = selectedUser.value.Identifier
|
formData.value.Identifier = selectedUser.value.Identifier
|
||||||
formData.value.Email = selectedUser.value.Email
|
formData.value.Email = selectedUser.value.Email
|
||||||
formData.value.Source = selectedUser.value.Source
|
formData.value.AuthSources = selectedUser.value.AuthSources
|
||||||
formData.value.IsAdmin = selectedUser.value.IsAdmin
|
formData.value.IsAdmin = selectedUser.value.IsAdmin
|
||||||
formData.value.Firstname = selectedUser.value.Firstname
|
formData.value.Firstname = selectedUser.value.Firstname
|
||||||
formData.value.Lastname = selectedUser.value.Lastname
|
formData.value.Lastname = selectedUser.value.Lastname
|
||||||
@@ -80,6 +80,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
|
|||||||
formData.value.Password = ""
|
formData.value.Password = ""
|
||||||
formData.value.Disabled = selectedUser.value.Disabled
|
formData.value.Disabled = selectedUser.value.Disabled
|
||||||
formData.value.Locked = selectedUser.value.Locked
|
formData.value.Locked = selectedUser.value.Locked
|
||||||
|
formData.value.PersistLocalChanges = selectedUser.value.PersistLocalChanges
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +134,7 @@ async function del() {
|
|||||||
<template>
|
<template>
|
||||||
<Modal :title="title" :visible="visible" @close="close">
|
<Modal :title="title" :visible="visible" @close="close">
|
||||||
<template #default>
|
<template #default>
|
||||||
<fieldset v-if="formData.Source==='db'">
|
<fieldset>
|
||||||
<legend class="mt-4">{{ $t('modals.user-edit.header-general') }}</legend>
|
<legend class="mt-4">{{ $t('modals.user-edit.header-general') }}</legend>
|
||||||
<div v-if="props.userId==='#NEW#'" class="form-group">
|
<div v-if="props.userId==='#NEW#'" class="form-group">
|
||||||
<label class="form-label mt-4">{{ $t('modals.user-edit.identifier.label') }}</label>
|
<label class="form-label mt-4">{{ $t('modals.user-edit.identifier.label') }}</label>
|
||||||
@@ -141,16 +142,22 @@ async function del() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label mt-4">{{ $t('modals.user-edit.source.label') }}</label>
|
<label class="form-label mt-4">{{ $t('modals.user-edit.source.label') }}</label>
|
||||||
<input v-model="formData.Source" class="form-control" disabled="disabled" :placeholder="$t('modals.user-edit.source.placeholder')" type="text">
|
<input v-model="formData.AuthSources" class="form-control" disabled="disabled" :placeholder="$t('modals.user-edit.source.placeholder')" type="text">
|
||||||
</div>
|
</div>
|
||||||
<div v-if="formData.Source==='db'" class="form-group">
|
<div class="form-group" v-if="formData.AuthSources.some(s => s ==='db')">
|
||||||
<label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label>
|
<label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label>
|
||||||
<input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :class="{ 'is-invalid': passwordWeak, 'is-valid': formData.Password !== '' && !passwordWeak }" :placeholder="$t('modals.user-edit.password.placeholder')" type="password">
|
<input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :class="{ 'is-invalid': passwordWeak, 'is-valid': formData.Password !== '' && !passwordWeak }" :placeholder="$t('modals.user-edit.password.placeholder')" type="password">
|
||||||
<div class="invalid-feedback">{{ $t('modals.user-edit.password.too-weak') }}</div>
|
<div class="invalid-feedback">{{ $t('modals.user-edit.password.too-weak') }}</div>
|
||||||
<small v-if="props.userId!=='#NEW#'" id="passwordHelp" class="form-text text-muted">{{ $t('modals.user-edit.password.description') }}</small>
|
<small v-if="props.userId!=='#NEW#'" id="passwordHelp" class="form-text text-muted">{{ $t('modals.user-edit.password.description') }}</small>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset v-if="formData.Source==='db'">
|
<fieldset v-if="formData.AuthSources.some(s => s !=='db') && !formData.PersistLocalChanges">
|
||||||
|
<legend class="mt-4">{{ $t('modals.user-edit.header-personal') }}</legend>
|
||||||
|
<div class="alert alert-warning mt-3">
|
||||||
|
{{ $t('modals.user-edit.sync-warning') }}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset v-if="!formData.AuthSources.some(s => s !=='db') || formData.PersistLocalChanges">
|
||||||
<legend class="mt-4">{{ $t('modals.user-edit.header-personal') }}</legend>
|
<legend class="mt-4">{{ $t('modals.user-edit.header-personal') }}</legend>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label mt-4">{{ $t('modals.user-edit.email.label') }}</label>
|
<label class="form-label mt-4">{{ $t('modals.user-edit.email.label') }}</label>
|
||||||
@@ -194,10 +201,14 @@ async function del() {
|
|||||||
<input v-model="formData.Locked" class="form-check-input" type="checkbox">
|
<input v-model="formData.Locked" class="form-check-input" type="checkbox">
|
||||||
<label class="form-check-label" >{{ $t('modals.user-edit.locked.label') }}</label>
|
<label class="form-check-label" >{{ $t('modals.user-edit.locked.label') }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch" v-if="formData.Source==='db'">
|
<div class="form-check form-switch" v-if="!formData.AuthSources.some(s => s !=='db') || formData.PersistLocalChanges">
|
||||||
<input v-model="formData.IsAdmin" checked="" class="form-check-input" type="checkbox">
|
<input v-model="formData.IsAdmin" checked="" class="form-check-input" type="checkbox">
|
||||||
<label class="form-check-label">{{ $t('modals.user-edit.admin.label') }}</label>
|
<label class="form-check-label">{{ $t('modals.user-edit.admin.label') }}</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check form-switch" v-if="formData.AuthSources.some(s => s !=='db')">
|
||||||
|
<input v-model="formData.PersistLocalChanges" class="form-check-input" type="checkbox">
|
||||||
|
<label class="form-check-label" >{{ $t('modals.user-edit.persist-local-changes.label') }}</label>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export function base64_url_encode(input) {
|
export function base64_url_encode(input) {
|
||||||
let output = btoa(input)
|
let output = btoa(input)
|
||||||
output = output.replace('+', '.')
|
output = output.replaceAll('+', '.')
|
||||||
output = output.replace('/', '_')
|
output = output.replaceAll('/', '_')
|
||||||
output = output.replace('=', '-')
|
output = output.replaceAll('=', '-')
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ export function freshInterface() {
|
|||||||
Disabled: false,
|
Disabled: false,
|
||||||
DisplayName: "",
|
DisplayName: "",
|
||||||
Identifier: "",
|
Identifier: "",
|
||||||
|
CreateDefaultPeer: false,
|
||||||
Mode: "server",
|
Mode: "server",
|
||||||
Backend: "local",
|
Backend: "local",
|
||||||
|
|
||||||
@@ -136,7 +137,7 @@ export function freshUser() {
|
|||||||
Identifier: "",
|
Identifier: "",
|
||||||
|
|
||||||
Email: "",
|
Email: "",
|
||||||
Source: "db",
|
AuthSources: ["db"],
|
||||||
IsAdmin: false,
|
IsAdmin: false,
|
||||||
|
|
||||||
Firstname: "",
|
Firstname: "",
|
||||||
@@ -154,6 +155,8 @@ export function freshUser() {
|
|||||||
|
|
||||||
ApiEnabled: false,
|
ApiEnabled: false,
|
||||||
|
|
||||||
|
PersistLocalChanges: false,
|
||||||
|
|
||||||
PeerCount: 0,
|
PeerCount: 0,
|
||||||
|
|
||||||
// Internal values
|
// Internal values
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ export function ipToBigInt(ip) {
|
|||||||
// Check if it's an IPv4 address
|
// Check if it's an IPv4 address
|
||||||
if (ip.includes(".")) {
|
if (ip.includes(".")) {
|
||||||
const addr = new Address4(ip)
|
const addr = new Address4(ip)
|
||||||
return addr.bigInteger()
|
return addr.bigInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, assume it's an IPv6 address
|
// Otherwise, assume it's an IPv6 address
|
||||||
const addr = new Address6(ip)
|
const addr = new Address6(ip)
|
||||||
return addr.bigInteger()
|
return addr.bigInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function humanFileSize(size) {
|
export function humanFileSize(size) {
|
||||||
|
|||||||
@@ -42,7 +42,8 @@
|
|||||||
"audit": "Event Protokoll",
|
"audit": "Event Protokoll",
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
"logout": "Abmelden",
|
"logout": "Abmelden",
|
||||||
"keygen": "Schlüsselgenerator"
|
"keygen": "Schlüsselgenerator",
|
||||||
|
"calculator": "IP-Rechner"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"headline": "WireGuard® VPN Portal",
|
"headline": "WireGuard® VPN Portal",
|
||||||
@@ -128,6 +129,11 @@
|
|||||||
"button-add-peers": "Mehrere Peers hinzufügen",
|
"button-add-peers": "Mehrere Peers hinzufügen",
|
||||||
"button-show-peer": "Peer anzeigen",
|
"button-show-peer": "Peer anzeigen",
|
||||||
"button-edit-peer": "Peer bearbeiten",
|
"button-edit-peer": "Peer bearbeiten",
|
||||||
|
"button-bulk-delete": "Ausgewählte Peers löschen",
|
||||||
|
"button-bulk-enable": "Ausgewählte Peers aktivieren",
|
||||||
|
"button-bulk-disable": "Ausgewählte Peers deaktivieren",
|
||||||
|
"confirm-bulk-delete": "Sind Sie sicher, dass Sie {count} Peers löschen möchten?",
|
||||||
|
"confirm-bulk-disable": "Sind Sie sicher, dass Sie {count} Peers deaktivieren möchten?",
|
||||||
"peer-disabled": "Peer ist deaktiviert, Grund:",
|
"peer-disabled": "Peer ist deaktiviert, Grund:",
|
||||||
"peer-expiring": "Peer läuft ab am",
|
"peer-expiring": "Peer läuft ab am",
|
||||||
"peer-connected": "Verbunden",
|
"peer-connected": "Verbunden",
|
||||||
@@ -141,7 +147,7 @@
|
|||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
"firstname": "Vorname",
|
"firstname": "Vorname",
|
||||||
"lastname": "Nachname",
|
"lastname": "Nachname",
|
||||||
"source": "Quelle",
|
"sources": "Quellen",
|
||||||
"peers": "Peers",
|
"peers": "Peers",
|
||||||
"admin": "Admin"
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
@@ -152,6 +158,14 @@
|
|||||||
"button-add-user": "Benutzer hinzufügen",
|
"button-add-user": "Benutzer hinzufügen",
|
||||||
"button-show-user": "Benutzer anzeigen",
|
"button-show-user": "Benutzer anzeigen",
|
||||||
"button-edit-user": "Benutzer bearbeiten",
|
"button-edit-user": "Benutzer bearbeiten",
|
||||||
|
"button-bulk-delete": "Ausgewählte Benutzer löschen",
|
||||||
|
"button-bulk-enable": "Ausgewählte Benutzer aktivieren",
|
||||||
|
"button-bulk-disable": "Ausgewählte Benutzer deaktivieren",
|
||||||
|
"button-bulk-lock": "Ausgewählte Benutzer sperren",
|
||||||
|
"button-bulk-unlock": "Ausgewählte Benutzer entsperren",
|
||||||
|
"confirm-bulk-delete": "Sind Sie sicher, dass Sie {count} Benutzer löschen möchten?",
|
||||||
|
"confirm-bulk-disable": "Sind Sie sicher, dass Sie {count} Benutzer deaktivieren möchten?",
|
||||||
|
"confirm-bulk-lock": "Sind Sie sicher, dass Sie {count} Benutzer sperren möchten?",
|
||||||
"user-disabled": "Benutzer ist deaktiviert, Grund:",
|
"user-disabled": "Benutzer ist deaktiviert, Grund:",
|
||||||
"user-locked": "Konto ist gesperrt, Grund:",
|
"user-locked": "Konto ist gesperrt, Grund:",
|
||||||
"admin": "Benutzer hat Administratorrechte",
|
"admin": "Benutzer hat Administratorrechte",
|
||||||
@@ -221,6 +235,16 @@
|
|||||||
"button-delete-text": "Passkey löschen. Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.",
|
"button-delete-text": "Passkey löschen. Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.",
|
||||||
"button-register-title": "Passkey registrieren",
|
"button-register-title": "Passkey registrieren",
|
||||||
"button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern."
|
"button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern."
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"headline": "Passwort-Einstellungen",
|
||||||
|
"abstract": "Hier können Sie Ihr Passwort ändern.",
|
||||||
|
"current-label": "Aktuelles Passwort",
|
||||||
|
"new-label": "Neues Passwort",
|
||||||
|
"new-confirm-label": "Neues Passwort bestätigen",
|
||||||
|
"change-button-text": "Passwort ändern",
|
||||||
|
"invalid-confirm-label": "Passwörter stimmen nicht überein",
|
||||||
|
"weak-label": "Passwort ist zu schwach"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"audit": {
|
"audit": {
|
||||||
@@ -259,6 +283,26 @@
|
|||||||
"placeholder": "Der geteilte Schlüssel"
|
"placeholder": "Der geteilte Schlüssel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"calculator": {
|
||||||
|
"headline": "WireGuard IP-Rechner",
|
||||||
|
"abstract": "Erzeuge erlaubte IPs für WireGuard. Die IP-Subnetze werden lokal in Ihrem Browser generiert und niemals an den Server gesendet.",
|
||||||
|
"headline-allowed-ip": "Neue erlaubte IPs",
|
||||||
|
"button-exclude-private": "Private IP-Bereiche ausschließen",
|
||||||
|
"allowed-ip": {
|
||||||
|
"label": "Erlaubte IPs",
|
||||||
|
"placeholder": "0.0.0.0/0, ::/0",
|
||||||
|
"empty": "Wert darf nicht leer sein"
|
||||||
|
},
|
||||||
|
"dissallowed-ip": {
|
||||||
|
"label": "Nicht erlaubte IPs",
|
||||||
|
"placeholder": "10.0.0.0/8, 192.168.0.0/16",
|
||||||
|
"invalid": "Ungültige Adresse: {addr}"
|
||||||
|
},
|
||||||
|
"new-allowed-ip": {
|
||||||
|
"label": "Erlaubte IPs",
|
||||||
|
"placeholder": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"user-view": {
|
"user-view": {
|
||||||
"headline": "Benutzerkonto:",
|
"headline": "Benutzerkonto:",
|
||||||
@@ -334,7 +378,11 @@
|
|||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"label": "Ist Administrator"
|
"label": "Ist Administrator"
|
||||||
}
|
},
|
||||||
|
"persist-local-changes": {
|
||||||
|
"label": "Lokale Änderungen speichern"
|
||||||
|
},
|
||||||
|
"sync-warning": "Um diesen synchronisierten Benutzer zu bearbeiten, aktivieren Sie die lokale Änderungsspeicherung. Andernfalls werden Ihre Änderungen bei der nächsten Synchronisierung überschrieben."
|
||||||
},
|
},
|
||||||
"interface-view": {
|
"interface-view": {
|
||||||
"headline": "Konfiguration für Schnittstelle:"
|
"headline": "Konfiguration für Schnittstelle:"
|
||||||
@@ -425,6 +473,9 @@
|
|||||||
"disabled": {
|
"disabled": {
|
||||||
"label": "Schnittstelle deaktiviert"
|
"label": "Schnittstelle deaktiviert"
|
||||||
},
|
},
|
||||||
|
"create-default-peer": {
|
||||||
|
"label": "Peer für neue Benutzer automatisch erstellen"
|
||||||
|
},
|
||||||
"save-config": {
|
"save-config": {
|
||||||
"label": "wg-quick Konfiguration automatisch speichern"
|
"label": "wg-quick Konfiguration automatisch speichern"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,7 +42,8 @@
|
|||||||
"audit": "Audit Log",
|
"audit": "Audit Log",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"keygen": "Key Generator"
|
"keygen": "Key Generator",
|
||||||
|
"calculator": "IP Calculator"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"headline": "WireGuard® VPN Portal",
|
"headline": "WireGuard® VPN Portal",
|
||||||
@@ -128,6 +129,11 @@
|
|||||||
"button-add-peers": "Add Multiple Peers",
|
"button-add-peers": "Add Multiple Peers",
|
||||||
"button-show-peer": "Show Peer",
|
"button-show-peer": "Show Peer",
|
||||||
"button-edit-peer": "Edit Peer",
|
"button-edit-peer": "Edit Peer",
|
||||||
|
"button-bulk-delete": "Delete selected peers",
|
||||||
|
"button-bulk-enable": "Enable selected peers",
|
||||||
|
"button-bulk-disable": "Disable selected peers",
|
||||||
|
"confirm-bulk-delete": "Are you sure you want to delete {count} peers?",
|
||||||
|
"confirm-bulk-disable": "Are you sure you want to disable {count} peers?",
|
||||||
"peer-disabled": "Peer is disabled, reason:",
|
"peer-disabled": "Peer is disabled, reason:",
|
||||||
"peer-expiring": "Peer is expiring at",
|
"peer-expiring": "Peer is expiring at",
|
||||||
"peer-connected": "Connected",
|
"peer-connected": "Connected",
|
||||||
@@ -141,7 +147,7 @@
|
|||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
"firstname": "Firstname",
|
"firstname": "Firstname",
|
||||||
"lastname": "Lastname",
|
"lastname": "Lastname",
|
||||||
"source": "Source",
|
"sources": "Sources",
|
||||||
"peers": "Peers",
|
"peers": "Peers",
|
||||||
"admin": "Admin"
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
@@ -152,6 +158,14 @@
|
|||||||
"button-add-user": "Add User",
|
"button-add-user": "Add User",
|
||||||
"button-show-user": "Show User",
|
"button-show-user": "Show User",
|
||||||
"button-edit-user": "Edit User",
|
"button-edit-user": "Edit User",
|
||||||
|
"button-bulk-delete": "Delete selected users",
|
||||||
|
"button-bulk-enable": "Enable selected users",
|
||||||
|
"button-bulk-disable": "Disable selected users",
|
||||||
|
"button-bulk-lock": "Lock selected users",
|
||||||
|
"button-bulk-unlock": "Unlock selected users",
|
||||||
|
"confirm-bulk-delete": "Are you sure you want to delete {count} users?",
|
||||||
|
"confirm-bulk-disable": "Are you sure you want to disable {count} users?",
|
||||||
|
"confirm-bulk-lock": "Are you sure you want to lock {count} users?",
|
||||||
"user-disabled": "User is disabled, reason:",
|
"user-disabled": "User is disabled, reason:",
|
||||||
"user-locked": "Account is locked, reason:",
|
"user-locked": "Account is locked, reason:",
|
||||||
"admin": "User has administrator privileges",
|
"admin": "User has administrator privileges",
|
||||||
@@ -221,6 +235,16 @@
|
|||||||
"button-delete-text": "Delete the passkey. You will not be able to log in with this passkey anymore.",
|
"button-delete-text": "Delete the passkey. You will not be able to log in with this passkey anymore.",
|
||||||
"button-register-title": "Register Passkey",
|
"button-register-title": "Register Passkey",
|
||||||
"button-register-text": "Register a new Passkey to secure your account."
|
"button-register-text": "Register a new Passkey to secure your account."
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"headline": "Password Settings",
|
||||||
|
"abstract": "Here you can change your password.",
|
||||||
|
"current-label": "Current Password",
|
||||||
|
"new-label": "New Password",
|
||||||
|
"new-confirm-label": "Confirm New Password",
|
||||||
|
"change-button-text": "Change Password",
|
||||||
|
"invalid-confirm-label": "Passwords do not match",
|
||||||
|
"weak-label": "Password is too weak"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"audit": {
|
"audit": {
|
||||||
@@ -259,6 +283,26 @@
|
|||||||
"placeholder": "The pre-shared key"
|
"placeholder": "The pre-shared key"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"calculator": {
|
||||||
|
"headline": "WireGuard IP Calculator",
|
||||||
|
"abstract": "Generate a WireGuard Allowed IPs. The IP subnets are generated in your local browser and are never sent to the server.",
|
||||||
|
"headline-allowed-ip": "New Allowed IPs",
|
||||||
|
"button-exclude-private": "Exclude Private IP Ranges",
|
||||||
|
"allowed-ip": {
|
||||||
|
"label": "Allowed IPs",
|
||||||
|
"placeholder": "0.0.0.0/0, ::/0",
|
||||||
|
"empty": "Value cannot be empty"
|
||||||
|
},
|
||||||
|
"dissallowed-ip": {
|
||||||
|
"label": "Disallowed IPs",
|
||||||
|
"placeholder": "10.0.0.0/8, 192.168.0.0/16",
|
||||||
|
"invalid": "Invalid address: {addr}"
|
||||||
|
},
|
||||||
|
"new-allowed-ip": {
|
||||||
|
"label": "Allowed IPs",
|
||||||
|
"placeholder": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"user-view": {
|
"user-view": {
|
||||||
"headline": "User Account:",
|
"headline": "User Account:",
|
||||||
@@ -334,7 +378,11 @@
|
|||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"label": "Is Admin"
|
"label": "Is Admin"
|
||||||
}
|
},
|
||||||
|
"persist-local-changes": {
|
||||||
|
"label": "Persist local changes"
|
||||||
|
},
|
||||||
|
"sync-warning": "To modify this synchronized user, enable local change persistence. Otherwise, your changes will be overwritten during the next synchronization."
|
||||||
},
|
},
|
||||||
"interface-view": {
|
"interface-view": {
|
||||||
"headline": "Config for Interface:"
|
"headline": "Config for Interface:"
|
||||||
@@ -425,6 +473,9 @@
|
|||||||
"disabled": {
|
"disabled": {
|
||||||
"label": "Interface Disabled"
|
"label": "Interface Disabled"
|
||||||
},
|
},
|
||||||
|
"create-default-peer": {
|
||||||
|
"label": "Create default peer for new users"
|
||||||
|
},
|
||||||
"save-config": {
|
"save-config": {
|
||||||
"label": "Automatically save wg-quick config"
|
"label": "Automatically save wg-quick config"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,26 @@
|
|||||||
"languages": {
|
"languages": {
|
||||||
"es": "Español"
|
"es": "Español"
|
||||||
},
|
},
|
||||||
|
"calculator": {
|
||||||
|
"abstract": "Genera direcciones IP permitidas de WireGuard. Las subredes IP se generan en tu navegador local y nunca se envían al servidor.",
|
||||||
|
"allowed-ip": {
|
||||||
|
"empty": "El valor no puede estar vacío",
|
||||||
|
"label": "IPs permitidas",
|
||||||
|
"placeholder": "0.0.0.0/0, ::/0"
|
||||||
|
},
|
||||||
|
"button-exclude-private": "Excluir rangos de IP privadas",
|
||||||
|
"dissallowed-ip": {
|
||||||
|
"invalid": "Dirección inválida: {addr}",
|
||||||
|
"label": "IPs no permitidas",
|
||||||
|
"placeholder": "10.0.0.0/8, 192.168.0.0/16"
|
||||||
|
},
|
||||||
|
"headline": "Calculadora de IPs de WireGuard",
|
||||||
|
"headline-allowed-ip": "Nuevas IPs permitidas",
|
||||||
|
"new-allowed-ip": {
|
||||||
|
"label": "IPs permitidas",
|
||||||
|
"placeholder": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"size": "Numero de elementos",
|
"size": "Numero de elementos",
|
||||||
@@ -33,6 +53,7 @@
|
|||||||
"button-webauthn": "Usar clave de acceso"
|
"button-webauthn": "Usar clave de acceso"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
|
"calculator": "Calculadora IP",
|
||||||
"home": "Inicio",
|
"home": "Inicio",
|
||||||
"interfaces": "Interfaces",
|
"interfaces": "Interfaces",
|
||||||
"users": "Usuarios",
|
"users": "Usuarios",
|
||||||
@@ -69,7 +90,7 @@
|
|||||||
"profiles": {
|
"profiles": {
|
||||||
"headline": "Perfiles VPN",
|
"headline": "Perfiles VPN",
|
||||||
"abstract": "Puedes acceder y descargar tus configuraciones personales de VPN desde tu perfil de usuario.",
|
"abstract": "Puedes acceder y descargar tus configuraciones personales de VPN desde tu perfil de usuario.",
|
||||||
"content": "para ver todos tus perfiles configurados, haz clic en el botón de abajo.",
|
"content": "Para ver todos tus perfiles configurados, haz clic en el botón de abajo.",
|
||||||
"button": "Abrir mi perfil"
|
"button": "Abrir mi perfil"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
@@ -96,7 +117,7 @@
|
|||||||
"table-heading": {
|
"table-heading": {
|
||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
"user": "Usuario",
|
"user": "Usuario",
|
||||||
"ip": "IP's",
|
"ip": "IPs",
|
||||||
"endpoint": "Endpoint",
|
"endpoint": "Endpoint",
|
||||||
"status": "Estado"
|
"status": "Estado"
|
||||||
},
|
},
|
||||||
@@ -114,6 +135,7 @@
|
|||||||
"total-endpoints": "Endpoints totales",
|
"total-endpoints": "Endpoints totales",
|
||||||
"ip": "Dirección IP",
|
"ip": "Dirección IP",
|
||||||
"default-allowed-ip": "IPs permitidas por defecto",
|
"default-allowed-ip": "IPs permitidas por defecto",
|
||||||
|
"default-dns": "Servidores DNS por defecto",
|
||||||
"dns": "Servidores DNS",
|
"dns": "Servidores DNS",
|
||||||
"mtu": "MTU",
|
"mtu": "MTU",
|
||||||
"default-keep-alive": "Intervalo Keepalive por defecto",
|
"default-keep-alive": "Intervalo Keepalive por defecto",
|
||||||
@@ -140,7 +162,7 @@
|
|||||||
"email": "Correo electrónico",
|
"email": "Correo electrónico",
|
||||||
"firstname": "Nombre",
|
"firstname": "Nombre",
|
||||||
"lastname": "Apellido",
|
"lastname": "Apellido",
|
||||||
"source": "Origen",
|
"sources": "Origen",
|
||||||
"peers": "Peers",
|
"peers": "Peers",
|
||||||
"admin": "Administrador"
|
"admin": "Administrador"
|
||||||
},
|
},
|
||||||
@@ -160,7 +182,7 @@
|
|||||||
"headline": "Mis peers VPN",
|
"headline": "Mis peers VPN",
|
||||||
"table-heading": {
|
"table-heading": {
|
||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
"ip": "IP's",
|
"ip": "IPs",
|
||||||
"stats": "Estado",
|
"stats": "Estado",
|
||||||
"interface": "Interfaz del servidor"
|
"interface": "Interfaz del servidor"
|
||||||
},
|
},
|
||||||
@@ -220,6 +242,16 @@
|
|||||||
"button-delete-text": "Eliminar la llave de acceso. Ya no podrás iniciar sesión con ella.",
|
"button-delete-text": "Eliminar la llave de acceso. Ya no podrás iniciar sesión con ella.",
|
||||||
"button-register-title": "Registrar llave de acceso",
|
"button-register-title": "Registrar llave de acceso",
|
||||||
"button-register-text": "Registrar una nueva llave de acceso para proteger tu cuenta."
|
"button-register-text": "Registrar una nueva llave de acceso para proteger tu cuenta."
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"headline": "Configuración de contraseña",
|
||||||
|
"abstract": "Aquí puedes cambiar tu contraseña.",
|
||||||
|
"current-label": "Contraseña actual",
|
||||||
|
"new-label": "Nueva contraseña",
|
||||||
|
"new-confirm-label": "Confirmar nueva contraseña",
|
||||||
|
"change-button-text": "Cambiar contraseña",
|
||||||
|
"invalid-confirm-label": "Las contraseñas no coinciden",
|
||||||
|
"weak-label": "La contraseña es demasiado débil"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"audit": {
|
"audit": {
|
||||||
@@ -269,7 +301,7 @@
|
|||||||
"firstname": "Nombre",
|
"firstname": "Nombre",
|
||||||
"lastname": "Apellido",
|
"lastname": "Apellido",
|
||||||
"phone": "Número de Teléfono",
|
"phone": "Número de Teléfono",
|
||||||
"depeertment": "Departamento",
|
"department": "Departamento",
|
||||||
"api-enabled": "Acceso API",
|
"api-enabled": "Acceso API",
|
||||||
"disabled": "Cuenta Deshabilitada",
|
"disabled": "Cuenta Deshabilitada",
|
||||||
"locked": "Cuenta Bloqueada",
|
"locked": "Cuenta Bloqueada",
|
||||||
@@ -277,7 +309,7 @@
|
|||||||
"peers": {
|
"peers": {
|
||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
"interface": "Interfaz",
|
"interface": "Interfaz",
|
||||||
"ip": "IP's"
|
"ip": "IPs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user-edit": {
|
"user-edit": {
|
||||||
@@ -309,7 +341,7 @@
|
|||||||
"label": "Teléfono",
|
"label": "Teléfono",
|
||||||
"placeholder": "El número de teléfono"
|
"placeholder": "El número de teléfono"
|
||||||
},
|
},
|
||||||
"depeertment": {
|
"department": {
|
||||||
"label": "Departamento",
|
"label": "Departamento",
|
||||||
"placeholder": "El departamento"
|
"placeholder": "El departamento"
|
||||||
},
|
},
|
||||||
@@ -338,6 +370,16 @@
|
|||||||
"interface-view": {
|
"interface-view": {
|
||||||
"headline": "Configuración de la interfaz:"
|
"headline": "Configuración de la interfaz:"
|
||||||
},
|
},
|
||||||
|
"password": {
|
||||||
|
"abstract": "Aquí puedes cambiar tu contraseña.",
|
||||||
|
"change-button-text": "Cambiar contraseña",
|
||||||
|
"current-label": "Contraseña actual",
|
||||||
|
"headline": "Configuración de contraseña",
|
||||||
|
"invalid-confirm-label": "Las contraseñas no coinciden",
|
||||||
|
"new-confirm-label": "Confirmar nueva contraseña",
|
||||||
|
"new-label": "Nueva contraseña",
|
||||||
|
"weak-label": "La contraseña es demasiado débil"
|
||||||
|
},
|
||||||
"interface-edit": {
|
"interface-edit": {
|
||||||
"headline-edit": "Editar interfaz:",
|
"headline-edit": "Editar interfaz:",
|
||||||
"headline-new": "Nueva interfaz",
|
"headline-new": "Nueva interfaz",
|
||||||
@@ -461,6 +503,8 @@
|
|||||||
"section-config": "Configuración",
|
"section-config": "Configuración",
|
||||||
"identifier": "Identificador",
|
"identifier": "Identificador",
|
||||||
"ip": "Direcciones IP",
|
"ip": "Direcciones IP",
|
||||||
|
"allowed-ip": "Direcciones IP permitidas",
|
||||||
|
"extra-allowed-ip": "Direcciones IP permitidas del lado del servidor",
|
||||||
"user": "Usuario Asociado",
|
"user": "Usuario Asociado",
|
||||||
"notes": "Notas",
|
"notes": "Notas",
|
||||||
"expiry-status": "Expira en",
|
"expiry-status": "Expira en",
|
||||||
@@ -469,10 +513,10 @@
|
|||||||
"connection-status": "Estadísticas de Conexión",
|
"connection-status": "Estadísticas de Conexión",
|
||||||
"upload": "Bytes Subidos (del Servidor al peer)",
|
"upload": "Bytes Subidos (del Servidor al peer)",
|
||||||
"download": "Bytes Descargados (del peer al Servidor)",
|
"download": "Bytes Descargados (del peer al Servidor)",
|
||||||
"pingable": "Es Alcanzable (Ping)",
|
"pingable": "Alcanzable (ping)",
|
||||||
"handshake": "Último Handshake",
|
"handshake": "Último handshake",
|
||||||
"connected-since": "Conectado desde",
|
"connected-since": "Conectado desde",
|
||||||
"endpoint": "Endpoint",
|
"endpoint": "Dirección del host remoto",
|
||||||
"button-download": "Descargar configuración",
|
"button-download": "Descargar configuración",
|
||||||
"button-email": "Enviar configuración por Correo Electrónico",
|
"button-email": "Enviar configuración por Correo Electrónico",
|
||||||
"style-label": "Estilo de Configuración"
|
"style-label": "Estilo de Configuración"
|
||||||
@@ -488,7 +532,7 @@
|
|||||||
"header-hooks": "Hooks (Ejecutados en el peer)",
|
"header-hooks": "Hooks (Ejecutados en el peer)",
|
||||||
"header-state": "Estado",
|
"header-state": "Estado",
|
||||||
"display-name": {
|
"display-name": {
|
||||||
"label": "Nombre para Mostrar",
|
"label": "Nombre para mostrar",
|
||||||
"placeholder": "El nombre descriptivo para el peer"
|
"placeholder": "El nombre descriptivo para el peer"
|
||||||
},
|
},
|
||||||
"linked-user": {
|
"linked-user": {
|
||||||
@@ -501,7 +545,7 @@
|
|||||||
"help": "La clave privada se almacena de forma segura en el servidor. Si el usuario ya posee una copia, puedes omitir este campo. El servidor sigue funcionando exclusivamente con la clave pública del peer."
|
"help": "La clave privada se almacena de forma segura en el servidor. Si el usuario ya posee una copia, puedes omitir este campo. El servidor sigue funcionando exclusivamente con la clave pública del peer."
|
||||||
},
|
},
|
||||||
"public-key": {
|
"public-key": {
|
||||||
"label": "Cave Pública",
|
"label": "Clave Pública",
|
||||||
"placeholder": "La Clave pública"
|
"placeholder": "La Clave pública"
|
||||||
},
|
},
|
||||||
"preshared-key": {
|
"preshared-key": {
|
||||||
@@ -512,6 +556,10 @@
|
|||||||
"label": "Dirección del endpoint",
|
"label": "Dirección del endpoint",
|
||||||
"placeholder": "La dirección del endpoint remoto"
|
"placeholder": "La dirección del endpoint remoto"
|
||||||
},
|
},
|
||||||
|
"endpoint-public-key": {
|
||||||
|
"label": "Clave pública del punto del endpoint",
|
||||||
|
"placeholder": "La clave pública del endpoint remoto"
|
||||||
|
},
|
||||||
"ip": {
|
"ip": {
|
||||||
"label": "Direcciones IP",
|
"label": "Direcciones IP",
|
||||||
"placeholder": "Direcciones IP (formato CIDR)"
|
"placeholder": "Direcciones IP (formato CIDR)"
|
||||||
@@ -576,11 +624,11 @@
|
|||||||
"description": "Un identificador de usuario (el nombre de usuario) para el cual debe crearse un peer."
|
"description": "Un identificador de usuario (el nombre de usuario) para el cual debe crearse un peer."
|
||||||
},
|
},
|
||||||
"prefix": {
|
"prefix": {
|
||||||
"headline-peer": "peer:",
|
"headline-peer": "Peer:",
|
||||||
"headline-endpoint": "Endpoint:",
|
"headline-endpoint": "Endpoint:",
|
||||||
"label": "Prefijo del Nombre peera Mostrar",
|
"label": "Prefijo del nombre del peer a mostrar",
|
||||||
"placeholder": "El prefijo",
|
"placeholder": "Prefijo",
|
||||||
"description": "Un prefijo que se agregará al nombre mostrado de los peers."
|
"description": "Un prefijo que se agregará al nombre visible de los peers."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
"email": "E-mail",
|
"email": "E-mail",
|
||||||
"firstname": "Prénom",
|
"firstname": "Prénom",
|
||||||
"lastname": "Nom",
|
"lastname": "Nom",
|
||||||
"source": "Source",
|
"sources": "Sources",
|
||||||
"peers": "Pairs",
|
"peers": "Pairs",
|
||||||
"admin": "Admin"
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -136,7 +136,7 @@
|
|||||||
"email": "이메일",
|
"email": "이메일",
|
||||||
"firstname": "이름",
|
"firstname": "이름",
|
||||||
"lastname": "성",
|
"lastname": "성",
|
||||||
"source": "소스",
|
"sources": "소스",
|
||||||
"peers": "피어",
|
"peers": "피어",
|
||||||
"admin": "관리자"
|
"admin": "관리자"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
"firstname": "Primeiro Nome",
|
"firstname": "Primeiro Nome",
|
||||||
"lastname": "Último Nome",
|
"lastname": "Último Nome",
|
||||||
"source": "Fonte",
|
"sources": "Fonte",
|
||||||
"peers": "Peers",
|
"peers": "Peers",
|
||||||
"admin": "Administrador"
|
"admin": "Administrador"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,7 +29,8 @@
|
|||||||
"label": "Пароль",
|
"label": "Пароль",
|
||||||
"placeholder": "Пожалуйста, введите ваш пароль"
|
"placeholder": "Пожалуйста, введите ваш пароль"
|
||||||
},
|
},
|
||||||
"button": "Войти"
|
"button": "Войти",
|
||||||
|
"button-webauthn": "Использовать Passkey"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"home": "Главная",
|
"home": "Главная",
|
||||||
@@ -37,8 +38,12 @@
|
|||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"lang": "Сменить язык",
|
"lang": "Сменить язык",
|
||||||
"profile": "Мой профиль",
|
"profile": "Мой профиль",
|
||||||
|
"settings": "Настройки",
|
||||||
|
"audit": "Журнал аудита",
|
||||||
"login": "Вход",
|
"login": "Вход",
|
||||||
"logout": "Выход"
|
"logout": "Выход",
|
||||||
|
"keygen": "Генератор ключей",
|
||||||
|
"calculator": "Калькулятор IP-адресов"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"headline": "Портал VPN WireGuard®",
|
"headline": "Портал VPN WireGuard®",
|
||||||
@@ -100,6 +105,8 @@
|
|||||||
"interface": {
|
"interface": {
|
||||||
"headline": "Статус интерфейса для",
|
"headline": "Статус интерфейса для",
|
||||||
"backend": "бэкэнд",
|
"backend": "бэкэнд",
|
||||||
|
"unknown-backend": "Неизвестно",
|
||||||
|
"wrong-backend": "Неверный бэкэнд, вместо него используется локальный сервер WireGuard!",
|
||||||
"key": "Публичный ключ",
|
"key": "Публичный ключ",
|
||||||
"endpoint": "Публичная конечная точка",
|
"endpoint": "Публичная конечная точка",
|
||||||
"port": "Порт прослушивания",
|
"port": "Порт прослушивания",
|
||||||
@@ -112,6 +119,7 @@
|
|||||||
"dns": "DNS-серверы",
|
"dns": "DNS-серверы",
|
||||||
"mtu": "MTU",
|
"mtu": "MTU",
|
||||||
"default-keep-alive": "Интервал поддержания активности по умолчанию",
|
"default-keep-alive": "Интервал поддержания активности по умолчанию",
|
||||||
|
"default-dns": "DNS-сервера по-умолчанию",
|
||||||
"button-show-config": "Показать конфигурацию",
|
"button-show-config": "Показать конфигурацию",
|
||||||
"button-download-config": "Скачать конфигурацию",
|
"button-download-config": "Скачать конфигурацию",
|
||||||
"button-store-config": "Сохранить конфигурацию для wg-quick",
|
"button-store-config": "Сохранить конфигурацию для wg-quick",
|
||||||
@@ -135,7 +143,7 @@
|
|||||||
"email": "Электронная почта",
|
"email": "Электронная почта",
|
||||||
"firstname": "Имя",
|
"firstname": "Имя",
|
||||||
"lastname": "Фамилия",
|
"lastname": "Фамилия",
|
||||||
"source": "Источник",
|
"sources": "Источник",
|
||||||
"peers": "Пиры",
|
"peers": "Пиры",
|
||||||
"admin": "Админ"
|
"admin": "Админ"
|
||||||
},
|
},
|
||||||
@@ -168,6 +176,121 @@
|
|||||||
"button-show-peer": "Показать пира",
|
"button-show-peer": "Показать пира",
|
||||||
"button-edit-peer": "Редактировать пира"
|
"button-edit-peer": "Редактировать пира"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"headline": "Настройки",
|
||||||
|
"abstract": "Здесь вы можете изменить персональные настройки.",
|
||||||
|
"api": {
|
||||||
|
"headline": "Настройки API",
|
||||||
|
"abstract": "Здесь можете настроить RESTful API.",
|
||||||
|
"active-description": "В данный момент API активен для вашей учетной записи. Все запросы API проверяются с помощью Basic Auth. Для проверки подлинности используйте следующие учетные данные.",
|
||||||
|
"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": "Настройки Passkey",
|
||||||
|
"abstract": "Passkey - это современный способ аутентификации пользователей без использования паролей. Он надежно хранятся в вашем браузере и могут быть использованы для входа в WireGuard Portal.",
|
||||||
|
"active-description": "В данный момент для вашей учетной записи пользователя активен по крайней мере один Passkey.",
|
||||||
|
"inactive-description": "В настоящее время для вашей учетной записи пользователя не зарегистрировано ни одного Passkey. Нажмите кнопку ниже, чтобы зарегистрировать новый Passkey.",
|
||||||
|
"table": {
|
||||||
|
"name": "Название",
|
||||||
|
"created": "Создано",
|
||||||
|
"actions": ""
|
||||||
|
},
|
||||||
|
"credentials-list": "Зарегистрированные Passkeys",
|
||||||
|
"modal-delete": {
|
||||||
|
"headline": "Удалить Passkey",
|
||||||
|
"abstract": "Вы уверены, что хотите удалить этот Passkey? Вы больше не сможете войти в систему с помощью этого Passkey.",
|
||||||
|
"created": "Создано:",
|
||||||
|
"button-delete": "Удалить",
|
||||||
|
"button-cancel": "Отмена"
|
||||||
|
},
|
||||||
|
"button-rename-title": "Переименновать",
|
||||||
|
"button-rename-text": "Переименновать Passkey.",
|
||||||
|
"button-save-title": "Сохранить",
|
||||||
|
"button-save-text": "Сохранить новое название Passkey.",
|
||||||
|
"button-cancel-title": "Отмена",
|
||||||
|
"button-cancel-text": "Отмена переименования Passkey.",
|
||||||
|
"button-delete-title": "Удалить",
|
||||||
|
"button-delete-text": "Удалить Passkey. Вы больше не сможете войти в систему с помощью этого Passkey.",
|
||||||
|
"button-register-title": "Зарегистрировать Passkey",
|
||||||
|
"button-register-text": "Зарегистрировать Passkey, чтобы защитить свою учетную запись."
|
||||||
|
},
|
||||||
|
"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": "Калькулятор IP-адресов",
|
||||||
|
"abstract": "Генерация разрешенных IP-адресов. IP-подсети генерируются в вашем локальном браузере и никогда не отправляются на сервер.",
|
||||||
|
"headline-allowed-ip": "Новые разрешенные IP-адреса",
|
||||||
|
"button-exclude-private": "Исключить частные диапазоны IP-адресов",
|
||||||
|
"allowed-ip": {
|
||||||
|
"label": "Разрешенные IP-адреса",
|
||||||
|
"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": "Разрешенные IP-адреса",
|
||||||
|
"placeholder": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"user-view": {
|
"user-view": {
|
||||||
"headline": "Учетная запись пользователя:",
|
"headline": "Учетная запись пользователя:",
|
||||||
@@ -180,6 +303,7 @@
|
|||||||
"lastname": "Фамилия",
|
"lastname": "Фамилия",
|
||||||
"phone": "Номер телефона",
|
"phone": "Номер телефона",
|
||||||
"department": "Отдел",
|
"department": "Отдел",
|
||||||
|
"api-enabled": "API",
|
||||||
"disabled": "Учетная запись отключена",
|
"disabled": "Учетная запись отключена",
|
||||||
"locked": "Учетная запись заблокирована",
|
"locked": "Учетная запись заблокирована",
|
||||||
"no-peers": "У пользователя нет связанных пиров.",
|
"no-peers": "У пользователя нет связанных пиров.",
|
||||||
@@ -207,7 +331,8 @@
|
|||||||
"password": {
|
"password": {
|
||||||
"label": "Пароль",
|
"label": "Пароль",
|
||||||
"placeholder": "Надежный пароль",
|
"placeholder": "Надежный пароль",
|
||||||
"description": "Оставьте это поле пустым, чтобы сохранить текущий пароль."
|
"description": "Оставьте это поле пустым, чтобы сохранить текущий пароль.",
|
||||||
|
"too-weak": "Пароль слишком простой. Используйте более сложный пароль."
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"label": "Электронная почта",
|
"label": "Электронная почта",
|
||||||
@@ -267,6 +392,11 @@
|
|||||||
"client": "Режим клиента",
|
"client": "Режим клиента",
|
||||||
"any": "Неизвестный режим"
|
"any": "Неизвестный режим"
|
||||||
},
|
},
|
||||||
|
"backend": {
|
||||||
|
"label": "Бэкэнд интерфейса",
|
||||||
|
"invalid-label": "Оригинальный бэкэнд больше недоступн, вместо нее используется локальная WireGuard-бэкэнд!",
|
||||||
|
"local": "Локальный WireGuard-бэкэнд"
|
||||||
|
},
|
||||||
"display-name": {
|
"display-name": {
|
||||||
"label": "Отображаемое имя",
|
"label": "Отображаемое имя",
|
||||||
"placeholder": "Описательное имя для интерфейса"
|
"placeholder": "Описательное имя для интерфейса"
|
||||||
@@ -364,6 +494,8 @@
|
|||||||
"section-config": "Конфигурация",
|
"section-config": "Конфигурация",
|
||||||
"identifier": "Идентификатор",
|
"identifier": "Идентификатор",
|
||||||
"ip": "IP-адреса",
|
"ip": "IP-адреса",
|
||||||
|
"allowed-ip": "Разрешённые IP-адреса",
|
||||||
|
"extra-allowed-ip": "Разрешённые IP-адреса на стороне сервера",
|
||||||
"user": "Связанный пользователь",
|
"user": "Связанный пользователь",
|
||||||
"notes": "Заметки",
|
"notes": "Заметки",
|
||||||
"expiry-status": "Истекает в",
|
"expiry-status": "Истекает в",
|
||||||
@@ -376,8 +508,10 @@
|
|||||||
"handshake": "Последнее рукопожатие",
|
"handshake": "Последнее рукопожатие",
|
||||||
"connected-since": "Подключен с",
|
"connected-since": "Подключен с",
|
||||||
"endpoint": "Конечная точка",
|
"endpoint": "Конечная точка",
|
||||||
|
"endpoint-key": "Публичный ключ конечной точки",
|
||||||
"button-download": "Скачать конфигурацию",
|
"button-download": "Скачать конфигурацию",
|
||||||
"button-email": "Отправить конфигурацию по электронной почте"
|
"button-email": "Отправить конфигурацию по электронной почте",
|
||||||
|
"style-label": "Вид конфигурации"
|
||||||
},
|
},
|
||||||
"peer-edit": {
|
"peer-edit": {
|
||||||
"headline-edit-peer": "Редактировать пира:",
|
"headline-edit-peer": "Редактировать пира:",
|
||||||
@@ -399,7 +533,8 @@
|
|||||||
},
|
},
|
||||||
"private-key": {
|
"private-key": {
|
||||||
"label": "Приватный ключ",
|
"label": "Приватный ключ",
|
||||||
"placeholder": "Приватный ключ"
|
"placeholder": "Приватный ключ",
|
||||||
|
"help": "Закрытый ключ надежно хранится на сервере. Если у пользователя уже есть копия, вы можете не указывать это поле. Сервер работает исключительно с открытым ключом клиента."
|
||||||
},
|
},
|
||||||
"public-key": {
|
"public-key": {
|
||||||
"label": "Публичный ключ",
|
"label": "Публичный ключ",
|
||||||
@@ -431,61 +566,61 @@
|
|||||||
"description": "Эти IP-адреса будут добавлены в удаленный интерфейс WireGuard как разрешенные IP-адреса."
|
"description": "Эти IP-адреса будут добавлены в удаленный интерфейс WireGuard как разрешенные IP-адреса."
|
||||||
},
|
},
|
||||||
"dns": {
|
"dns": {
|
||||||
"label": "DNS Server",
|
"label": "DNS-сервер",
|
||||||
"placeholder": "The DNS servers that should be used"
|
"placeholder": "Используемые DNS-серверы"
|
||||||
},
|
},
|
||||||
"dns-search": {
|
"dns-search": {
|
||||||
"label": "DNS Search Domains",
|
"label": "Поисковые домены DNS",
|
||||||
"placeholder": "DNS search prefixes"
|
"placeholder": "Префиксы поиска DNS"
|
||||||
},
|
},
|
||||||
"keep-alive": {
|
"keep-alive": {
|
||||||
"label": "Keep Alive Interval",
|
"label": "Интервал поддержания активности",
|
||||||
"placeholder": "Persistent Keepalive (0 = default)"
|
"placeholder": "Постоянное поддержание активности (0 = значение по умолчанию)"
|
||||||
},
|
},
|
||||||
"mtu": {
|
"mtu": {
|
||||||
"label": "MTU",
|
"label": "MTU",
|
||||||
"placeholder": "The client MTU (0 = keep default)"
|
"placeholder": "MTU клиента (0 = использовать значение по умолчанию)"
|
||||||
},
|
},
|
||||||
"pre-up": {
|
"pre-up": {
|
||||||
"label": "Pre-Up",
|
"label": "Pre-Up",
|
||||||
"placeholder": "One or multiple bash commands separated by ;"
|
"placeholder": "Одна или несколько команд bash, разделенных ;"
|
||||||
},
|
},
|
||||||
"post-up": {
|
"post-up": {
|
||||||
"label": "Post-Up",
|
"label": "Post-Up",
|
||||||
"placeholder": "One or multiple bash commands separated by ;"
|
"placeholder": "Одна или несколько команд bash, разделенных ;"
|
||||||
},
|
},
|
||||||
"pre-down": {
|
"pre-down": {
|
||||||
"label": "Pre-Down",
|
"label": "Pre-Down",
|
||||||
"placeholder": "One or multiple bash commands separated by ;"
|
"placeholder": "Одна или несколько команд bash, разделенных ;"
|
||||||
},
|
},
|
||||||
"post-down": {
|
"post-down": {
|
||||||
"label": "Post-Down",
|
"label": "Post-Down",
|
||||||
"placeholder": "One or multiple bash commands separated by ;"
|
"placeholder": "Одна или несколько команд bash, разделенных ;"
|
||||||
},
|
},
|
||||||
"disabled": {
|
"disabled": {
|
||||||
"label": "Peer Disabled"
|
"label": "Узел отключен"
|
||||||
},
|
},
|
||||||
"ignore-global": {
|
"ignore-global": {
|
||||||
"label": "Ignore global settings"
|
"label": "Игнорировать глобальные настройки"
|
||||||
},
|
},
|
||||||
"expires-at": {
|
"expires-at": {
|
||||||
"label": "Expiry date"
|
"label": "Дата истечения срока действия"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"peer-multi-create": {
|
"peer-multi-create": {
|
||||||
"headline-peer": "Create multiple peers",
|
"headline-peer": "Создать несколько узлов",
|
||||||
"headline-endpoint": "Create multiple endpoints",
|
"headline-endpoint": "Создать несколько конечных точек",
|
||||||
"identifiers": {
|
"identifiers": {
|
||||||
"label": "User Identifiers",
|
"label": "Идентификаторы пользователей",
|
||||||
"placeholder": "User Identifiers",
|
"placeholder": "Идентификаторы пользователей",
|
||||||
"description": "A user identifier (the username) for which a peer should be created."
|
"description": "Идентификатор пользователя (имя пользователя), для которого узел будет создан."
|
||||||
},
|
},
|
||||||
"prefix": {
|
"prefix": {
|
||||||
"headline-peer": "Peer:",
|
"headline-peer": "Узел:",
|
||||||
"headline-endpoint": "Endpoint:",
|
"headline-endpoint": "Конечная точка:",
|
||||||
"label": "Display Name Prefix",
|
"label": "Префикс отображаемого имени",
|
||||||
"placeholder": "The prefix",
|
"placeholder": "Префикс",
|
||||||
"description": "A prefix that is added to the peers display name."
|
"description": "Префикс будет добавлен к отображаемому имени узла."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@
|
|||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
"firstname": "Ім'я",
|
"firstname": "Ім'я",
|
||||||
"lastname": "Прізвище",
|
"lastname": "Прізвище",
|
||||||
"source": "Джерело",
|
"sources": "Джерело",
|
||||||
"peers": "Піри",
|
"peers": "Піри",
|
||||||
"admin": "Адміністратор"
|
"admin": "Адміністратор"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
"firstname": "Tên",
|
"firstname": "Tên",
|
||||||
"lastname": "Họ",
|
"lastname": "Họ",
|
||||||
"source": "Nguồn",
|
"sources": "Nguồn",
|
||||||
"peers": "Peers",
|
"peers": "Peers",
|
||||||
"admin": "Quản trị viên"
|
"admin": "Quản trị viên"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
"email": "电子邮件",
|
"email": "电子邮件",
|
||||||
"firstname": "名",
|
"firstname": "名",
|
||||||
"lastname": "姓",
|
"lastname": "姓",
|
||||||
"source": "来源",
|
"sources": "来源",
|
||||||
"peers": "节点",
|
"peers": "节点",
|
||||||
"admin": "管理员"
|
"admin": "管理员"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ const router = createRouter({
|
|||||||
// this generates a separate chunk (About.[hash].js) for this route
|
// this generates a separate chunk (About.[hash].js) for this route
|
||||||
// which is lazy-loaded when the route is visited.
|
// which is lazy-loaded when the route is visited.
|
||||||
component: () => import('../views/KeyGeneraterView.vue')
|
component: () => import('../views/KeyGeneraterView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/ip-calculator',
|
||||||
|
name: 'ip-calculator',
|
||||||
|
// route level code-splitting
|
||||||
|
// this generates a separate chunk (About.[hash].js) for this route
|
||||||
|
// which is lazy-loaded when the route is visited.
|
||||||
|
component: () => import('../views/IPCalculatorView.vue')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
linkActiveClass: "active",
|
linkActiveClass: "active",
|
||||||
@@ -122,7 +130,7 @@ router.beforeEach(async (to) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// redirect to login page if not logged in and trying to access a restricted page
|
// redirect to login page if not logged in and trying to access a restricted page
|
||||||
const publicPages = ['/', '/login', '/key-generator']
|
const publicPages = ['/', '/login', '/key-generator', '/ip-calculator']
|
||||||
const authRequired = !publicPages.includes(to.path)
|
const authRequired = !publicPages.includes(to.path)
|
||||||
|
|
||||||
if (authRequired && !auth.IsAuthenticated) {
|
if (authRequired && !auth.IsAuthenticated) {
|
||||||
|
|||||||
@@ -222,6 +222,73 @@ export const peerStore = defineStore('peers', {
|
|||||||
throw new Error(error)
|
throw new Error(error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
async BulkDelete(ids) {
|
||||||
|
this.fetching = true
|
||||||
|
return apiWrapper.post(`${baseUrl}/bulk-delete`, { Identifiers: ids })
|
||||||
|
.then(() => {
|
||||||
|
this.peers = this.peers.filter(p => !ids.includes(p.Identifier))
|
||||||
|
this.fetching = false
|
||||||
|
notify({
|
||||||
|
title: "Peers deleted",
|
||||||
|
text: "Selected peers have been deleted!",
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.fetching = false
|
||||||
|
console.log("Failed to delete peers: ", error)
|
||||||
|
notify({
|
||||||
|
title: "Backend Connection Failure",
|
||||||
|
text: "Failed to delete selected peers!",
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
throw new Error(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async BulkEnable(ids) {
|
||||||
|
this.fetching = true
|
||||||
|
return apiWrapper.post(`${baseUrl}/bulk-enable`, { Identifiers: ids })
|
||||||
|
.then(async () => {
|
||||||
|
await this.LoadPeers()
|
||||||
|
notify({
|
||||||
|
title: "Peers enabled",
|
||||||
|
text: "Selected peers have been enabled!",
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.fetching = false
|
||||||
|
console.log("Failed to enable peers: ", error)
|
||||||
|
notify({
|
||||||
|
title: "Backend Connection Failure",
|
||||||
|
text: "Failed to enable selected peers!",
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
throw new Error(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async BulkDisable(ids, reason) {
|
||||||
|
this.fetching = true
|
||||||
|
return apiWrapper.post(`${baseUrl}/bulk-disable`, { Identifiers: ids, Reason: reason })
|
||||||
|
.then(async () => {
|
||||||
|
await this.LoadPeers()
|
||||||
|
notify({
|
||||||
|
title: "Peers disabled",
|
||||||
|
text: "Selected peers have been disabled!",
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.fetching = false
|
||||||
|
console.log("Failed to disable peers: ", error)
|
||||||
|
notify({
|
||||||
|
title: "Backend Connection Failure",
|
||||||
|
text: "Failed to disable selected peers!",
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
throw new Error(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
async UpdatePeer(id, formData) {
|
async UpdatePeer(id, formData) {
|
||||||
this.fetching = true
|
this.fetching = true
|
||||||
return apiWrapper.put(`${baseUrl}/${base64_url_encode(id)}`, formData)
|
return apiWrapper.put(`${baseUrl}/${base64_url_encode(id)}`, formData)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import {apiWrapper} from "@/helpers/fetch-wrapper";
|
import {apiWrapper} from "@/helpers/fetch-wrapper";
|
||||||
import {notify} from "@kyvg/vue3-notification";
|
import {notify} from "@kyvg/vue3-notification";
|
||||||
import {authStore} from "@/stores/auth";
|
import {authStore} from "@/stores/auth";
|
||||||
|
import {peerStore} from "@/stores/peers";
|
||||||
import { base64_url_encode } from '@/helpers/encoding';
|
import { base64_url_encode } from '@/helpers/encoding';
|
||||||
import {freshStats} from "@/helpers/models";
|
import {freshStats} from "@/helpers/models";
|
||||||
import { ipToBigInt } from '@/helpers/utils';
|
import { ipToBigInt } from '@/helpers/utils';
|
||||||
@@ -151,6 +152,17 @@ export const profileStore = defineStore('profile', {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
async changePassword(formData) {
|
||||||
|
this.fetching = true
|
||||||
|
let currentUser = authStore().user.Identifier
|
||||||
|
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/change-password`, formData)
|
||||||
|
.then(this.fetching = false)
|
||||||
|
.catch(error => {
|
||||||
|
this.fetching = false;
|
||||||
|
console.log("Failed to change password for ", currentUser, ": ", error);
|
||||||
|
throw new Error(error);
|
||||||
|
});
|
||||||
|
},
|
||||||
async LoadPeers() {
|
async LoadPeers() {
|
||||||
this.fetching = true
|
this.fetching = true
|
||||||
let currentUser = authStore().user.Identifier
|
let currentUser = authStore().user.Identifier
|
||||||
@@ -207,5 +219,18 @@ export const profileStore = defineStore('profile', {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
async BulkDelete(ids) {
|
||||||
|
this.fetching = true
|
||||||
|
const peers = peerStore()
|
||||||
|
return peers.BulkDelete(ids)
|
||||||
|
.then(() => {
|
||||||
|
this.peers = this.peers.filter(p => !ids.includes(p.Identifier))
|
||||||
|
this.fetching = false
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.fetching = false
|
||||||
|
throw new Error(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -142,5 +142,140 @@ export const userStore = defineStore('users', {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
async BulkDelete(ids) {
|
||||||
|
this.fetching = true
|
||||||
|
return apiWrapper.post(`${baseUrl}/bulk-delete`, { Identifiers: ids })
|
||||||
|
.then(() => {
|
||||||
|
this.users = this.users.filter(u => !ids.includes(u.Identifier))
|
||||||
|
this.fetching = false
|
||||||
|
notify({
|
||||||
|
title: "Users deleted",
|
||||||
|
text: "Selected users have been deleted!",
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.fetching = false
|
||||||
|
console.log("Failed to delete users: ", error)
|
||||||
|
notify({
|
||||||
|
title: "Backend Connection Failure",
|
||||||
|
text: "Failed to delete selected users!",
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
throw new Error(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async BulkEnable(ids) {
|
||||||
|
this.fetching = true
|
||||||
|
return apiWrapper.post(`${baseUrl}/bulk-enable`, { Identifiers: ids })
|
||||||
|
.then(() => {
|
||||||
|
this.users.forEach(u => {
|
||||||
|
if (ids.includes(u.Identifier)) {
|
||||||
|
u.Disabled = false
|
||||||
|
u.DisabledReason = ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.fetching = false
|
||||||
|
notify({
|
||||||
|
title: "Users enabled",
|
||||||
|
text: "Selected users have been enabled!",
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.fetching = false
|
||||||
|
console.log("Failed to enable users: ", error)
|
||||||
|
notify({
|
||||||
|
title: "Backend Connection Failure",
|
||||||
|
text: "Failed to enable selected users!",
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
throw new Error(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async BulkDisable(ids, reason) {
|
||||||
|
this.fetching = true
|
||||||
|
return apiWrapper.post(`${baseUrl}/bulk-disable`, { Identifiers: ids, Reason: reason })
|
||||||
|
.then(() => {
|
||||||
|
this.users.forEach(u => {
|
||||||
|
if (ids.includes(u.Identifier)) {
|
||||||
|
u.Disabled = true
|
||||||
|
u.DisabledReason = reason
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.fetching = false
|
||||||
|
notify({
|
||||||
|
title: "Users disabled",
|
||||||
|
text: "Selected users have been disabled!",
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.fetching = false
|
||||||
|
console.log("Failed to disable users: ", error)
|
||||||
|
notify({
|
||||||
|
title: "Backend Connection Failure",
|
||||||
|
text: "Failed to disable selected users!",
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
throw new Error(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async BulkLock(ids, reason) {
|
||||||
|
this.fetching = true
|
||||||
|
return apiWrapper.post(`${baseUrl}/bulk-lock`, { Identifiers: ids, Reason: reason })
|
||||||
|
.then(() => {
|
||||||
|
this.users.forEach(u => {
|
||||||
|
if (ids.includes(u.Identifier)) {
|
||||||
|
u.Locked = true
|
||||||
|
u.LockedReason = reason
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.fetching = false
|
||||||
|
notify({
|
||||||
|
title: "Users locked",
|
||||||
|
text: "Selected users have been locked!",
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.fetching = false
|
||||||
|
console.log("Failed to lock users: ", error)
|
||||||
|
notify({
|
||||||
|
title: "Backend Connection Failure",
|
||||||
|
text: "Failed to lock selected users!",
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
throw new Error(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async BulkUnlock(ids) {
|
||||||
|
this.fetching = true
|
||||||
|
return apiWrapper.post(`${baseUrl}/bulk-unlock`, { Identifiers: ids })
|
||||||
|
.then(() => {
|
||||||
|
this.users.forEach(u => {
|
||||||
|
if (ids.includes(u.Identifier)) {
|
||||||
|
u.Locked = false
|
||||||
|
u.LockedReason = ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.fetching = false
|
||||||
|
notify({
|
||||||
|
title: "Users unlocked",
|
||||||
|
text: "Selected users have been unlocked!",
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.fetching = false
|
||||||
|
console.log("Failed to unlock users: ", error)
|
||||||
|
notify({
|
||||||
|
title: "Backend Connection Failure",
|
||||||
|
text: "Failed to unlock selected users!",
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
throw new Error(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
139
frontend/src/views/IPCalculatorView.vue
Normal file
139
frontend/src/views/IPCalculatorView.vue
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup>
|
||||||
|
|
||||||
|
import {ref, watch, computed} from "vue";
|
||||||
|
import isCidr from "is-cidr";
|
||||||
|
import {isIP} from "is-ip";
|
||||||
|
import {excludeCidr} from "cidr-tools";
|
||||||
|
import {useI18n} from 'vue-i18n';
|
||||||
|
|
||||||
|
const allowedIp = ref("")
|
||||||
|
const dissallowedIp = ref("")
|
||||||
|
const privateIP = ref("10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16")
|
||||||
|
|
||||||
|
const {t} = useI18n()
|
||||||
|
|
||||||
|
const errorAllowed = ref("")
|
||||||
|
const errorDissallowed = ref("")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a comma-separated list of IP and/or CIDR addresses.
|
||||||
|
* @function validateIpAndCidrList
|
||||||
|
* @param {string} value - Comma-separated string (e.g. "10.0.0.0/8, 192.168.0.1")
|
||||||
|
* @returns {true|string} Returns true if all values are valid, otherwise an error message.
|
||||||
|
*/
|
||||||
|
function validateIpAndCidrList(value) {
|
||||||
|
const list = value.split(",").map(v => v.trim()).filter(Boolean);
|
||||||
|
if (list.length === 0) {
|
||||||
|
return t('calculator.allowed-ip.empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const addr of list) {
|
||||||
|
if (!isIP(addr) && !isCidr(addr)) {
|
||||||
|
return t('calculator.dissallowed-ip.invalid', {addr});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watcher that validates allowed IPs input in real-time.
|
||||||
|
* Updates `errorAllowed` whenever `allowedIp` changes.
|
||||||
|
*/
|
||||||
|
watch(allowedIp, (newValue) => {
|
||||||
|
const result = validateIpAndCidrList(newValue);
|
||||||
|
errorAllowed.value = result === true ? "" : result;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watcher that validates disallowed IPs input in real-time.
|
||||||
|
* Updates `errorDissallowed` whenever `dissallowedIp` changes.
|
||||||
|
*/
|
||||||
|
watch(dissallowedIp, (newValue) => {
|
||||||
|
if (!allowedIp.value || allowedIp.value.trim() === "") {
|
||||||
|
allowedIp.value = "0.0.0.0/0";
|
||||||
|
}
|
||||||
|
const result = validateIpAndCidrList(newValue);
|
||||||
|
errorDissallowed.value = result === true ? "" : result;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamically computes the resulting "Allowed IPs" list
|
||||||
|
* by excluding the disallowed ranges from the allowed ranges.
|
||||||
|
* @constant
|
||||||
|
* @type {ComputedRef<string>}
|
||||||
|
* @returns {string} A comma-separated string of resulting CIDR blocks.
|
||||||
|
*/
|
||||||
|
const newAllowedIp = computed(() => {
|
||||||
|
if (errorAllowed.value || errorDissallowed.value) return "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allowedList = allowedIp.value.split(",").map(v => v.trim()).filter(Boolean);
|
||||||
|
const disallowedList = dissallowedIp.value.split(",").map(v => v.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const result = excludeCidr(allowedList, disallowedList);
|
||||||
|
|
||||||
|
return result.join(", ");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Allowed IPs calculation error:", e);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append private IP ranges to disallowed IPs.
|
||||||
|
* If any already exist, they are preserved and new ones are appended only if not present.
|
||||||
|
* @function addPrivateIPs
|
||||||
|
*/
|
||||||
|
function addPrivateIPs() {
|
||||||
|
const privateList = privateIP.value.split(",").map(v => v.trim());
|
||||||
|
const currentList = dissallowedIp.value.split(",").map(v => v.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const combined = Array.from(new Set([...currentList, ...privateList]));
|
||||||
|
dissallowedIp.value = combined.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>{{ $t('calculator.headline') }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="lead">{{ $t('calculator.abstract') }}</p>
|
||||||
|
|
||||||
|
<div class="mt-4 row">
|
||||||
|
<div class="col-12 col-lg-5">
|
||||||
|
<fieldset>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label mt-4">{{ $t('calculator.allowed-ip.label') }}</label>
|
||||||
|
<input class="form-control" v-model="allowedIp" :placeholder="$t('calculator.allowed-ip.placeholder')" :class="{ 'is-invalid': errorAllowed }">
|
||||||
|
<div v-if="errorAllowed" class="text-danger mt-1">{{ errorAllowed }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label mt-4">{{ $t('calculator.dissallowed-ip.label') }}</label>
|
||||||
|
<input class="form-control" v-model="dissallowedIp" :placeholder="$t('calculator.dissallowed-ip.placeholder')" :class="{ 'is-invalid': errorDissallowed }">
|
||||||
|
<div v-if="errorDissallowed" class="text-danger mt-1">{{ errorDissallowed }}</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<hr class="mt-4">
|
||||||
|
<button class="btn btn-primary mb-4" type="button" @click="addPrivateIPs">{{ $t('calculator.button-exclude-private') }}</button>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-2 mt-sm-4">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-5">
|
||||||
|
<h1>{{ $t('calculator.headline-allowed-ip') }}</h1>
|
||||||
|
<fieldset>
|
||||||
|
<div class="form-group">
|
||||||
|
<textarea class="form-control" :value="newAllowedIp" rows="6" :placeholder="$t('calculator.new-allowed-ip.placeholder')" readonly></textarea>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -29,6 +29,10 @@ const sortKey = ref("")
|
|||||||
const sortOrder = ref(1)
|
const sortOrder = ref(1)
|
||||||
const selectAll = ref(false)
|
const selectAll = ref(false)
|
||||||
|
|
||||||
|
const selectedPeers = computed(() => {
|
||||||
|
return peers.All.filter(peer => peer.IsSelected).map(peer => peer.Identifier);
|
||||||
|
})
|
||||||
|
|
||||||
function sortBy(key) {
|
function sortBy(key) {
|
||||||
if (sortKey.value === key) {
|
if (sortKey.value === key) {
|
||||||
sortOrder.value = sortOrder.value * -1; // Toggle sort order
|
sortOrder.value = sortOrder.value * -1; // Toggle sort order
|
||||||
@@ -111,6 +115,39 @@ async function saveConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function bulkDelete() {
|
||||||
|
if (confirm(t('interfaces.confirm-bulk-delete', {count: selectedPeers.value.length}))) {
|
||||||
|
try {
|
||||||
|
await peers.BulkDelete(selectedPeers.value)
|
||||||
|
selectAll.value = false // reset selection
|
||||||
|
} catch (e) {
|
||||||
|
// notification is handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkEnable() {
|
||||||
|
try {
|
||||||
|
await peers.BulkEnable(selectedPeers.value)
|
||||||
|
selectAll.value = false
|
||||||
|
peers.All.forEach(p => p.IsSelected = false) // remove selection
|
||||||
|
} catch (e) {
|
||||||
|
// notification is handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDisable() {
|
||||||
|
if (confirm(t('interfaces.confirm-bulk-disable', {count: selectedPeers.value.length}))) {
|
||||||
|
try {
|
||||||
|
await peers.BulkDisable(selectedPeers.value)
|
||||||
|
selectAll.value = false
|
||||||
|
peers.All.forEach(p => p.IsSelected = false) // remove selection
|
||||||
|
} catch (e) {
|
||||||
|
// notification is handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSelectAll() {
|
function toggleSelectAll() {
|
||||||
peers.FilteredAndPaged.forEach(peer => {
|
peers.FilteredAndPaged.forEach(peer => {
|
||||||
peer.IsSelected = selectAll.value;
|
peer.IsSelected = selectAll.value;
|
||||||
@@ -353,6 +390,13 @@ onMounted(async () => {
|
|||||||
<a class="btn btn-primary ms-2" href="#" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a>
|
<a class="btn btn-primary ms-2" href="#" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row" v-if="selectedPeers.length > 0">
|
||||||
|
<div class="col-12 text-lg-end">
|
||||||
|
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('interfaces.button-bulk-enable')" @click.prevent="bulkEnable"><i class="fa-regular fa-circle-check"></i></a>
|
||||||
|
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('interfaces.button-bulk-disable')" @click.prevent="bulkDisable"><i class="fa fa-ban"></i></a>
|
||||||
|
<a class="btn btn-outline-danger btn-sm ms-2" href="#" :title="$t('interfaces.button-bulk-delete')" @click.prevent="bulkDelete"><i class="fa fa-trash-can"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="interfaces.Count!==0" class="mt-2 table-responsive">
|
<div v-if="interfaces.Count!==0" class="mt-2 table-responsive">
|
||||||
<div v-if="peers.Count===0">
|
<div v-if="peers.Count===0">
|
||||||
<h4>{{ $t('interfaces.no-peer.headline') }}</h4>
|
<h4>{{ $t('interfaces.no-peer.headline') }}</h4>
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import PeerViewModal from "../components/PeerViewModal.vue";
|
import PeerViewModal from "../components/PeerViewModal.vue";
|
||||||
|
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref, computed } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import { profileStore } from "@/stores/profile";
|
import { profileStore } from "@/stores/profile";
|
||||||
|
import { peerStore } from "@/stores/peers";
|
||||||
import UserPeerEditModal from "@/components/UserPeerEditModal.vue";
|
import UserPeerEditModal from "@/components/UserPeerEditModal.vue";
|
||||||
import { settingsStore } from "@/stores/settings";
|
import { settingsStore } from "@/stores/settings";
|
||||||
import { humanFileSize } from "@/helpers/utils";
|
import { humanFileSize } from "@/helpers/utils";
|
||||||
|
|
||||||
const settings = settingsStore()
|
const settings = settingsStore()
|
||||||
const profile = profileStore()
|
const profile = profileStore()
|
||||||
|
const peers = peerStore()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const viewedPeerId = ref("")
|
const viewedPeerId = ref("")
|
||||||
const editPeerId = ref("")
|
const editPeerId = ref("")
|
||||||
@@ -17,6 +22,10 @@ const sortKey = ref("")
|
|||||||
const sortOrder = ref(1)
|
const sortOrder = ref(1)
|
||||||
const selectAll = ref(false)
|
const selectAll = ref(false)
|
||||||
|
|
||||||
|
const selectedPeers = computed(() => {
|
||||||
|
return profile.Peers.filter(peer => peer.IsSelected).map(peer => peer.Identifier);
|
||||||
|
})
|
||||||
|
|
||||||
function sortBy(key) {
|
function sortBy(key) {
|
||||||
if (sortKey.value === key) {
|
if (sortKey.value === key) {
|
||||||
sortOrder.value = sortOrder.value * -1; // Toggle sort order
|
sortOrder.value = sortOrder.value * -1; // Toggle sort order
|
||||||
@@ -35,6 +44,17 @@ function friendlyInterfaceName(id, name) {
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function bulkDelete() {
|
||||||
|
if (confirm(t('interfaces.confirm-bulk-delete', {count: selectedPeers.value.length}))) {
|
||||||
|
try {
|
||||||
|
await profile.BulkDelete(selectedPeers.value)
|
||||||
|
selectAll.value = false // reset selection
|
||||||
|
} catch (e) {
|
||||||
|
// notification is handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSelectAll() {
|
function toggleSelectAll() {
|
||||||
profile.FilteredAndPagedPeers.forEach(peer => {
|
profile.FilteredAndPagedPeers.forEach(peer => {
|
||||||
peer.IsSelected = selectAll.value;
|
peer.IsSelected = selectAll.value;
|
||||||
@@ -84,6 +104,13 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row" v-if="selectedPeers.length > 0">
|
||||||
|
<div class="col-12 text-lg-end">
|
||||||
|
<button class="btn btn-outline-danger btn-sm" :title="$t('interfaces.button-bulk-delete')" @click.prevent="bulkDelete">
|
||||||
|
<i class="fa fa-trash-can"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-2 table-responsive">
|
<div class="mt-2 table-responsive">
|
||||||
<div v-if="profile.CountPeers === 0">
|
<div v-if="profile.CountPeers === 0">
|
||||||
<h4>{{ $t('profile.no-peer.headline') }}</h4>
|
<h4>{{ $t('profile.no-peer.headline') }}</h4>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {onMounted, ref} from "vue";
|
import {computed, onMounted, ref} from "vue";
|
||||||
import { profileStore } from "@/stores/profile";
|
import { profileStore } from "@/stores/profile";
|
||||||
import { settingsStore } from "@/stores/settings";
|
import { settingsStore } from "@/stores/settings";
|
||||||
import { authStore } from "../stores/auth";
|
import { authStore } from "../stores/auth";
|
||||||
|
import {notify} from "@kyvg/vue3-notification";
|
||||||
|
|
||||||
const profile = profileStore()
|
const profile = profileStore()
|
||||||
const settings = settingsStore()
|
const settings = settingsStore()
|
||||||
const auth = authStore()
|
const auth = authStore()
|
||||||
|
|
||||||
|
const webBasePath = ref(WGPORTAL_BASE_PATH);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await profile.LoadUser()
|
await profile.LoadUser()
|
||||||
await auth.LoadWebAuthnCredentials()
|
await auth.LoadWebAuthnCredentials()
|
||||||
@@ -34,6 +37,45 @@ async function saveRename(credential) {
|
|||||||
console.error("Failed to rename credential:", error);
|
console.error("Failed to rename credential:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pwFormData = ref({
|
||||||
|
OldPassword: '',
|
||||||
|
Password: '',
|
||||||
|
PasswordRepeat: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordWeak = computed(() => {
|
||||||
|
return pwFormData.value.Password && pwFormData.value.Password.length > 0 && pwFormData.value.Password.length < settings.Setting('MinPasswordLength')
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordChangeAllowed = computed(() => {
|
||||||
|
return pwFormData.value.Password && pwFormData.value.Password.length >= settings.Setting('MinPasswordLength') &&
|
||||||
|
pwFormData.value.Password === pwFormData.value.PasswordRepeat &&
|
||||||
|
pwFormData.value.OldPassword && pwFormData.value.OldPassword.length > 0 && pwFormData.value.OldPassword !== pwFormData.value.Password;
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatePassword = async () => {
|
||||||
|
try {
|
||||||
|
await profile.changePassword(pwFormData.value);
|
||||||
|
|
||||||
|
pwFormData.value.OldPassword = '';
|
||||||
|
pwFormData.value.Password = '';
|
||||||
|
pwFormData.value.PasswordRepeat = '';
|
||||||
|
notify({
|
||||||
|
title: "Password changed!",
|
||||||
|
text: "Your password has been changed successfully.",
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
notify({
|
||||||
|
title: "Failed to update password!",
|
||||||
|
text: e.toString(),
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -43,52 +85,45 @@ async function saveRename(credential) {
|
|||||||
|
|
||||||
<p class="lead">{{ $t('settings.abstract') }}</p>
|
<p class="lead">{{ $t('settings.abstract') }}</p>
|
||||||
|
|
||||||
<div v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')">
|
<div class="card border-secondary p-5 mt-5" v-if="profile.user.Source === 'db'">
|
||||||
<div class="card border-secondary p-5" v-if="profile.user.ApiToken">
|
<h2 class="display-7">{{ $t('settings.password.headline') }}</h2>
|
||||||
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
|
<p class="lead">{{ $t('settings.password.abstract') }}</p>
|
||||||
<p class="lead">{{ $t('settings.api.abstract') }}</p>
|
<hr class="my-4">
|
||||||
<hr class="my-4">
|
|
||||||
<p>{{ $t('settings.api.active-description') }}</p>
|
<div class="row">
|
||||||
<div class="row">
|
<div class="col-6">
|
||||||
<div class="col-6">
|
<div class="form-group">
|
||||||
<div class="form-group">
|
<label class="form-label mt-4" for="oldpw">{{ $t('settings.password.current-label') }}</label>
|
||||||
<label class="form-label mt-4">{{ $t('settings.api.user-label') }}</label>
|
<input id="oldpw" v-model="pwFormData.OldPassword" class="form-control" :class="{ 'is-invalid': pwFormData.Password && !pwFormData.OldPassword }" type="password">
|
||||||
<input v-model="profile.user.Identifier" class="form-control" :placeholder="$t('settings.api.user-placeholder')" type="text" readonly>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label mt-4">{{ $t('settings.api.token-label') }}</label>
|
|
||||||
<input v-model="profile.user.ApiToken" class="form-control" :placeholder="$t('settings.api.token-placeholder')" type="text" readonly>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="col-6">
|
||||||
<div class="col-12">
|
</div>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<p class="form-label mt-4">{{ $t('settings.api.token-created-label') }} {{profile.user.ApiTokenCreated}}</p>
|
<div class="row">
|
||||||
</div>
|
<div class="col-6">
|
||||||
|
<div class="form-group has-success">
|
||||||
|
<label class="form-label mt-4" for="newpw">{{ $t('settings.password.new-label') }}</label>
|
||||||
|
<input id="newpw" v-model="pwFormData.Password" class="form-control" :class="{ 'is-invalid': passwordWeak, 'is-valid': pwFormData.Password !== '' && !passwordWeak }" type="password">
|
||||||
|
<div class="invalid-feedback" v-if="passwordWeak">{{ $t('settings.password.weak-label') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mt-5">
|
<div class="col-6">
|
||||||
<div class="col-6">
|
<div class="form-group">
|
||||||
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching">
|
<label class="form-label mt-4" for="confirmnewpw">{{ $t('settings.password.new-confirm-label') }}</label>
|
||||||
<i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }}
|
<input id="confirmnewpw" v-model="pwFormData.PasswordRepeat" class="form-control" :class="{ 'is-invalid': pwFormData.PasswordRepeat !== ''&& pwFormData.Password !== pwFormData.PasswordRepeat, 'is-valid': pwFormData.PasswordRepeat !== '' && pwFormData.Password === pwFormData.PasswordRepeat && !passwordWeak }" type="password">
|
||||||
</button>
|
<div class="invalid-feedback" v-if="pwFormData.PasswordRepeat !== ''&& pwFormData.Password !== pwFormData.PasswordRepeat">{{ $t('settings.password.invalid-confirm-label') }}</div>
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<a href="/api/v1/doc.html" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card border-secondary p-5" v-else>
|
<div class="row mt-5">
|
||||||
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
|
<div class="col-6">
|
||||||
<p class="lead">{{ $t('settings.api.abstract') }}</p>
|
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="updatePassword" :disabled="profile.isFetching || !passwordChangeAllowed">
|
||||||
<hr class="my-4">
|
<i class="fa-solid fa-floppy-disk"></i> {{ $t('settings.password.change-button-text') }}
|
||||||
<p>{{ $t('settings.api.inactive-description') }}</p>
|
</button>
|
||||||
<button class="btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching">
|
</div>
|
||||||
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }}
|
<div class="col-6">
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -173,4 +208,53 @@ async function saveRename(credential) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')">
|
||||||
|
<div class="card border-secondary p-5" v-if="profile.user.ApiToken">
|
||||||
|
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
|
||||||
|
<p class="lead">{{ $t('settings.api.abstract') }}</p>
|
||||||
|
<hr class="my-4">
|
||||||
|
<p>{{ $t('settings.api.active-description') }}</p>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label mt-4">{{ $t('settings.api.user-label') }}</label>
|
||||||
|
<input v-model="profile.user.Identifier" class="form-control" :placeholder="$t('settings.api.user-placeholder')" type="text" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label mt-4">{{ $t('settings.api.token-label') }}</label>
|
||||||
|
<input v-model="profile.user.ApiToken" class="form-control" :placeholder="$t('settings.api.token-placeholder')" type="text" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-group">
|
||||||
|
<p class="form-label mt-4">{{ $t('settings.api.token-created-label') }} {{profile.user.ApiTokenCreated}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-5">
|
||||||
|
<div class="col-6">
|
||||||
|
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching">
|
||||||
|
<i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<a :href="webBasePath + '/api/v1/doc.html'" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card border-secondary p-5" v-else>
|
||||||
|
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
|
||||||
|
<p class="lead">{{ $t('settings.api.abstract') }}</p>
|
||||||
|
<hr class="my-4">
|
||||||
|
<p>{{ $t('settings.api.inactive-description') }}</p>
|
||||||
|
<button class="btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching">
|
||||||
|
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,16 +1,77 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {userStore} from "@/stores/users";
|
import {userStore} from "@/stores/users";
|
||||||
import {ref,onMounted} 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 {useI18n} from "vue-i18n";
|
||||||
|
|
||||||
const users = userStore()
|
const users = userStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const editUserId = ref("")
|
const editUserId = ref("")
|
||||||
const viewedUserId = ref("")
|
const viewedUserId = ref("")
|
||||||
|
|
||||||
const selectAll = ref(false)
|
const selectAll = ref(false)
|
||||||
|
|
||||||
|
const selectedUsers = computed(() => {
|
||||||
|
return users.All.filter(user => user.IsSelected).map(user => user.Identifier);
|
||||||
|
})
|
||||||
|
|
||||||
|
async function bulkDelete() {
|
||||||
|
if (confirm(t('users.confirm-bulk-delete', {count: selectedUsers.value.length}))) {
|
||||||
|
try {
|
||||||
|
await users.BulkDelete(selectedUsers.value)
|
||||||
|
selectAll.value = false // reset selection
|
||||||
|
} catch (e) {
|
||||||
|
// notification is handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkEnable() {
|
||||||
|
try {
|
||||||
|
await users.BulkEnable(selectedUsers.value)
|
||||||
|
selectAll.value = false
|
||||||
|
users.All.forEach(u => u.IsSelected = false) // remove selection
|
||||||
|
} catch (e) {
|
||||||
|
// notification is handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDisable() {
|
||||||
|
if (confirm(t('users.confirm-bulk-disable', {count: selectedUsers.value.length}))) {
|
||||||
|
try {
|
||||||
|
await users.BulkDisable(selectedUsers.value)
|
||||||
|
selectAll.value = false
|
||||||
|
users.All.forEach(u => u.IsSelected = false) // remove selection
|
||||||
|
} catch (e) {
|
||||||
|
// notification is handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkLock() {
|
||||||
|
if (confirm(t('users.confirm-bulk-lock', {count: selectedUsers.value.length}))) {
|
||||||
|
try {
|
||||||
|
await users.BulkLock(selectedUsers.value)
|
||||||
|
selectAll.value = false
|
||||||
|
users.All.forEach(u => u.IsSelected = false) // remove selection
|
||||||
|
} catch (e) {
|
||||||
|
// notification is handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkUnlock() {
|
||||||
|
try {
|
||||||
|
await users.BulkUnlock(selectedUsers.value)
|
||||||
|
selectAll.value = false
|
||||||
|
users.All.forEach(u => u.IsSelected = false) // remove selection
|
||||||
|
} catch (e) {
|
||||||
|
// notification is handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSelectAll() {
|
function toggleSelectAll() {
|
||||||
users.FilteredAndPaged.forEach(user => {
|
users.FilteredAndPaged.forEach(user => {
|
||||||
user.IsSelected = selectAll.value;
|
user.IsSelected = selectAll.value;
|
||||||
@@ -45,6 +106,15 @@ onMounted(() => {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row" v-if="selectedUsers.length > 0">
|
||||||
|
<div class="col-12 text-lg-end">
|
||||||
|
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('users.button-bulk-enable')" @click.prevent="bulkEnable"><i class="fa-regular fa-circle-check"></i></a>
|
||||||
|
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('users.button-bulk-disable')" @click.prevent="bulkDisable"><i class="fa fa-ban"></i></a>
|
||||||
|
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('users.button-bulk-unlock')" @click.prevent="bulkUnlock"><i class="fa-solid fa-lock-open"></i></a>
|
||||||
|
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('users.button-bulk-lock')" @click.prevent="bulkLock"><i class="fa-solid fa-lock"></i></a>
|
||||||
|
<a class="btn btn-outline-danger btn-sm ms-2" href="#" :title="$t('users.button-bulk-delete')" @click.prevent="bulkDelete"><i class="fa fa-trash-can"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-2 table-responsive">
|
<div class="mt-2 table-responsive">
|
||||||
<div v-if="users.Count===0">
|
<div v-if="users.Count===0">
|
||||||
<h4>{{ $t('users.no-user.headline') }}</h4>
|
<h4>{{ $t('users.no-user.headline') }}</h4>
|
||||||
@@ -61,7 +131,7 @@ onMounted(() => {
|
|||||||
<th scope="col">{{ $t('users.table-heading.email') }}</th>
|
<th scope="col">{{ $t('users.table-heading.email') }}</th>
|
||||||
<th scope="col">{{ $t('users.table-heading.firstname') }}</th>
|
<th scope="col">{{ $t('users.table-heading.firstname') }}</th>
|
||||||
<th scope="col">{{ $t('users.table-heading.lastname') }}</th>
|
<th scope="col">{{ $t('users.table-heading.lastname') }}</th>
|
||||||
<th class="text-center" scope="col">{{ $t('users.table-heading.source') }}</th>
|
<th class="text-center" scope="col">{{ $t('users.table-heading.sources') }}</th>
|
||||||
<th class="text-center" scope="col">{{ $t('users.table-heading.peers') }}</th>
|
<th class="text-center" scope="col">{{ $t('users.table-heading.peers') }}</th>
|
||||||
<th class="text-center" scope="col">{{ $t('users.table-heading.admin') }}</th>
|
<th class="text-center" scope="col">{{ $t('users.table-heading.admin') }}</th>
|
||||||
<th scope="col"></th><!-- Actions -->
|
<th scope="col"></th><!-- Actions -->
|
||||||
@@ -80,7 +150,7 @@ onMounted(() => {
|
|||||||
<td>{{user.Email}}</td>
|
<td>{{user.Email}}</td>
|
||||||
<td>{{user.Firstname}}</td>
|
<td>{{user.Firstname}}</td>
|
||||||
<td>{{user.Lastname}}</td>
|
<td>{{user.Lastname}}</td>
|
||||||
<td class="text-center"><span class="badge rounded-pill bg-light">{{user.Source}}</span></td>
|
<td><span class="badge bg-light me-1" v-for="src in user.AuthSources" :key="src">{{src}}</span></td>
|
||||||
<td class="text-center">{{user.PeerCount}}</td>
|
<td class="text-center">{{user.PeerCount}}</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<span v-if="user.IsAdmin" class="text-danger" :title="$t('users.admin')"><i class="fa fa-check-circle"></i></span>
|
<span v-if="user.IsAdmin" class="text-danger" :title="$t('users.admin')"><i class="fa fa-check-circle"></i></span>
|
||||||
|
|||||||
73
go.mod
73
go.mod
@@ -5,12 +5,12 @@ go 1.24.0
|
|||||||
require (
|
require (
|
||||||
github.com/a8m/envsubst v1.4.3
|
github.com/a8m/envsubst v1.4.3
|
||||||
github.com/alexedwards/scs/v2 v2.9.0
|
github.com/alexedwards/scs/v2 v2.9.0
|
||||||
github.com/coreos/go-oidc/v3 v3.16.0
|
github.com/coreos/go-oidc/v3 v3.17.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/go-ldap/ldap/v3 v3.4.12
|
github.com/go-ldap/ldap/v3 v3.4.12
|
||||||
github.com/go-pkgz/routegroup v1.5.3
|
github.com/go-pkgz/routegroup v1.6.0
|
||||||
github.com/go-playground/validator/v10 v10.28.0
|
github.com/go-playground/validator/v10 v10.30.1
|
||||||
github.com/go-webauthn/webauthn v0.14.0
|
github.com/go-webauthn/webauthn v0.15.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/prometheus-community/pro-bing v0.7.0
|
github.com/prometheus-community/pro-bing v0.7.0
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
@@ -21,50 +21,51 @@ require (
|
|||||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||||
github.com/yeqown/go-qrcode/v2 v2.2.5
|
github.com/yeqown/go-qrcode/v2 v2.2.5
|
||||||
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
|
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
|
||||||
golang.org/x/crypto v0.42.0
|
golang.org/x/crypto v0.46.0
|
||||||
golang.org/x/oauth2 v0.31.0
|
golang.org/x/oauth2 v0.34.0
|
||||||
golang.org/x/sys v0.36.0
|
golang.org/x/sys v0.39.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.1
|
gorm.io/driver/sqlserver v1.6.3
|
||||||
gorm.io/gorm v1.31.0
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
github.com/Azure/go-ntlmssp v0.1.0 // 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.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // 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.3 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
github.com/go-openapi/jsonreference v0.21.4 // indirect
|
||||||
github.com/go-openapi/spec v0.22.0 // indirect
|
github.com/go-openapi/spec v0.22.2 // indirect
|
||||||
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||||
github.com/go-test/deep v1.1.1 // indirect
|
github.com/go-test/deep v1.1.1 // indirect
|
||||||
github.com/go-webauthn/x v0.1.25 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/go-webauthn/x v0.1.26 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/go-tpm v0.9.6 // indirect
|
github.com/google/go-tpm v0.9.7 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||||
@@ -76,32 +77,32 @@ require (
|
|||||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||||
github.com/mdlayher/netlink v1.8.0 // indirect
|
github.com/mdlayher/netlink v1.8.0 // indirect
|
||||||
github.com/mdlayher/socket v0.5.1 // indirect
|
github.com/mdlayher/socket v0.5.1 // indirect
|
||||||
github.com/microsoft/go-mssqldb v1.9.3 // indirect
|
github.com/microsoft/go-mssqldb v1.9.5 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.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 v0.1.9 // indirect
|
github.com/ncruces/go-strftime v1.0.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.66.1 // indirect
|
github.com/prometheus/common v0.67.4 // indirect
|
||||||
github.com/prometheus/procfs v0.17.0 // indirect
|
github.com/prometheus/procfs v0.19.2 // 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/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.3 // 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-20251002181428-27f1f14c8bb9 // indirect
|
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
|
||||||
golang.org/x/mod v0.28.0 // indirect
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
golang.org/x/net v0.44.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/tools v0.37.0 // indirect
|
golang.org/x/tools v0.40.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.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
modernc.org/libc v1.66.10 // indirect
|
modernc.org/libc v1.67.1 // 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.39.0 // indirect
|
modernc.org/sqlite v1.40.1 // indirect
|
||||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
166
go.sum
166
go.sum
@@ -20,8 +20,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:
|
|||||||
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.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
|
||||||
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.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
github.com/Azure/go-ntlmssp v0.1.0/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.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||||
@@ -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.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||||
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
@@ -50,8 +50,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/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=
|
||||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
@@ -62,47 +62,53 @@ github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZR
|
|||||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||||
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||||
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
|
||||||
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
|
||||||
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
|
github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc=
|
||||||
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
|
github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
|
||||||
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.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||||
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||||
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||||
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||||
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||||
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||||
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||||
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||||
github.com/go-pkgz/routegroup v1.5.3 h1:IvH1KLcQkMap9jucQGBlef3IBloxSAe8USUFvxShFqs=
|
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||||
github.com/go-pkgz/routegroup v1.5.3/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
|
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||||
|
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||||
|
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||||
|
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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
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.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
|
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
|
||||||
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
|
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
|
||||||
|
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
|
||||||
|
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
|
||||||
github.com/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=
|
||||||
@@ -115,8 +121,8 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO
|
|||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
|
github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
|
||||||
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
@@ -127,6 +133,8 @@ github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/z
|
|||||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
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=
|
||||||
@@ -173,18 +181,16 @@ github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHi
|
|||||||
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
||||||
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||||
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.3 h1:hy4p+LDC8LIGvI3JATnLVmBOLMJbmn5X400mr5j0lPs=
|
github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
|
||||||
github.com/microsoft/go-mssqldb v1.9.3/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
|
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
|
||||||
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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
|
||||||
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=
|
||||||
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
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 v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
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=
|
||||||
@@ -197,15 +203,17 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
|||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
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.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
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=
|
||||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -262,18 +270,18 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM
|
|||||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
|
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
|
||||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
|
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||||
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.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
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=
|
||||||
@@ -291,10 +299,10 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
|||||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -302,8 +310,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -324,8 +332,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|||||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/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=
|
||||||
@@ -354,16 +362,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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-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.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
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=
|
||||||
@@ -385,23 +393,25 @@ gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
|||||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY=
|
gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI=
|
||||||
gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
|
gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
|
||||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||||
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||||
|
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
|
||||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||||
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=
|
||||||
@@ -410,8 +420,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||||
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||||
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=
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ import (
|
|||||||
"github.com/h44z/wg-portal/internal/domain"
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SchemaVersion describes the current database schema version. It must be incremented if a manual migration is needed.
|
|
||||||
var SchemaVersion uint64 = 1
|
|
||||||
|
|
||||||
// SysStat stores the current database schema version and the timestamp when it was applied.
|
// SysStat stores the current database schema version and the timestamp when it was applied.
|
||||||
type SysStat struct {
|
type SysStat struct {
|
||||||
MigratedAt time.Time `gorm:"column:migrated_at"`
|
MigratedAt time.Time `gorm:"column:migrated_at"`
|
||||||
@@ -166,6 +163,9 @@ func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
|
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := os.Chmod(cfg.DSN, 0600); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set permissions on sqlite database: %w", err)
|
||||||
|
}
|
||||||
sqlDB, _ := gormDb.DB()
|
sqlDB, _ := gormDb.DB()
|
||||||
sqlDB.SetMaxOpenConns(1)
|
sqlDB.SetMaxOpenConns(1)
|
||||||
}
|
}
|
||||||
@@ -176,13 +176,15 @@ func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
|||||||
// SqlRepo is a SQL database repository implementation.
|
// SqlRepo is a SQL database repository implementation.
|
||||||
// Currently, it supports MySQL, SQLite, Microsoft SQL and Postgresql database systems.
|
// Currently, it supports MySQL, SQLite, Microsoft SQL and Postgresql database systems.
|
||||||
type SqlRepo struct {
|
type SqlRepo struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSqlRepository creates a new SqlRepo instance.
|
// NewSqlRepository creates a new SqlRepo instance.
|
||||||
func NewSqlRepository(db *gorm.DB) (*SqlRepo, error) {
|
func NewSqlRepository(db *gorm.DB, cfg *config.Config) (*SqlRepo, error) {
|
||||||
repo := &SqlRepo{
|
repo := &SqlRepo{
|
||||||
db: db,
|
db: db,
|
||||||
|
cfg: cfg,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.preCheck(); err != nil {
|
if err := repo.preCheck(); err != nil {
|
||||||
@@ -220,6 +222,8 @@ func (r *SqlRepo) preCheck() error {
|
|||||||
func (r *SqlRepo) migrate() error {
|
func (r *SqlRepo) migrate() error {
|
||||||
slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{}))
|
slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{}))
|
||||||
slog.Debug("running migration: user", "result", r.db.AutoMigrate(&domain.User{}))
|
slog.Debug("running migration: user", "result", r.db.AutoMigrate(&domain.User{}))
|
||||||
|
slog.Debug("running migration: user authentications", "result",
|
||||||
|
r.db.AutoMigrate(&domain.UserAuthentication{}))
|
||||||
slog.Debug("running migration: user webauthn credentials", "result",
|
slog.Debug("running migration: user webauthn credentials", "result",
|
||||||
r.db.AutoMigrate(&domain.UserWebauthnCredential{}))
|
r.db.AutoMigrate(&domain.UserWebauthnCredential{}))
|
||||||
slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{}))
|
slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{}))
|
||||||
@@ -229,16 +233,88 @@ func (r *SqlRepo) migrate() error {
|
|||||||
slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{}))
|
slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{}))
|
||||||
|
|
||||||
existingSysStat := SysStat{}
|
existingSysStat := SysStat{}
|
||||||
r.db.Where("schema_version = ?", SchemaVersion).First(&existingSysStat)
|
r.db.Order("schema_version desc").First(&existingSysStat) // get latest version
|
||||||
|
|
||||||
|
// Migration: 0 --> 1
|
||||||
if existingSysStat.SchemaVersion == 0 {
|
if existingSysStat.SchemaVersion == 0 {
|
||||||
|
const schemaVersion = 1
|
||||||
sysStat := SysStat{
|
sysStat := SysStat{
|
||||||
MigratedAt: time.Now(),
|
MigratedAt: time.Now(),
|
||||||
SchemaVersion: SchemaVersion,
|
SchemaVersion: schemaVersion,
|
||||||
}
|
}
|
||||||
if err := r.db.Create(&sysStat).Error; err != nil {
|
if err := r.db.Create(&sysStat).Error; err != nil {
|
||||||
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", SchemaVersion, err)
|
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
|
||||||
}
|
}
|
||||||
slog.Debug("sys-stat entry written", "schema_version", SchemaVersion)
|
slog.Debug("sys-stat entry written", "schema_version", schemaVersion)
|
||||||
|
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: 1 --> 2
|
||||||
|
if existingSysStat.SchemaVersion == 1 {
|
||||||
|
const schemaVersion = 2
|
||||||
|
// Preserve existing behavior for installations that had default-peer-creation enabled.
|
||||||
|
if r.cfg.Core.CreateDefaultPeer {
|
||||||
|
err := r.db.Model(&domain.Interface{}).
|
||||||
|
Where("type = ?", domain.InterfaceTypeServer).
|
||||||
|
Update("create_default_peer", true).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to migrate interface flags for schema version %d: %w", schemaVersion, err)
|
||||||
|
}
|
||||||
|
slog.Debug("migrated interface create_default_peer flags", "schema_version", schemaVersion)
|
||||||
|
}
|
||||||
|
sysStat := SysStat{
|
||||||
|
MigratedAt: time.Now(),
|
||||||
|
SchemaVersion: schemaVersion,
|
||||||
|
}
|
||||||
|
if err := r.db.Create(&sysStat).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
|
||||||
|
}
|
||||||
|
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: 2 --> 3
|
||||||
|
if existingSysStat.SchemaVersion == 2 {
|
||||||
|
const schemaVersion = 3
|
||||||
|
// Migration to multi-auth
|
||||||
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var users []domain.User
|
||||||
|
if err := tx.Find(&users).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for _, user := range users {
|
||||||
|
auth := domain.UserAuthentication{
|
||||||
|
BaseModel: domain.BaseModel{
|
||||||
|
CreatedBy: domain.CtxSystemDBMigrator,
|
||||||
|
UpdatedBy: domain.CtxSystemDBMigrator,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
UserIdentifier: user.Identifier,
|
||||||
|
Source: user.Source,
|
||||||
|
ProviderName: user.ProviderName,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&auth).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("migrated users to multi-auth model", "schema_version", schemaVersion)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to migrate to multi-auth: %w", err)
|
||||||
|
}
|
||||||
|
sysStat := SysStat{
|
||||||
|
MigratedAt: time.Now(),
|
||||||
|
SchemaVersion: schemaVersion,
|
||||||
|
}
|
||||||
|
if err := r.db.Create(&sysStat).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
|
||||||
|
}
|
||||||
|
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -748,7 +824,7 @@ func (r *SqlRepo) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr
|
|||||||
func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
|
func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
|
||||||
var user domain.User
|
var user domain.User
|
||||||
|
|
||||||
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").First(&user, id).Error
|
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Preload("Authentications").First(&user, id).Error
|
||||||
|
|
||||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, domain.ErrNotFound
|
return nil, domain.ErrNotFound
|
||||||
@@ -766,7 +842,8 @@ func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domai
|
|||||||
func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||||
var users []domain.User
|
var users []domain.User
|
||||||
|
|
||||||
err := r.db.WithContext(ctx).Where("email = ?", email).Preload("WebAuthnCredentialList").Find(&users).Error
|
err := r.db.WithContext(ctx).Where("email = ?",
|
||||||
|
email).Preload("WebAuthnCredentialList").Preload("Authentications").Find(&users).Error
|
||||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, domain.ErrNotFound
|
return nil, domain.ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -806,7 +883,7 @@ func (r *SqlRepo) GetUserByWebAuthnCredential(ctx context.Context, credentialIdB
|
|||||||
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
||||||
var users []domain.User
|
var users []domain.User
|
||||||
|
|
||||||
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Find(&users).Error
|
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Preload("Authentications").Find(&users).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -826,6 +903,7 @@ func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User,
|
|||||||
Or("lastname LIKE ?", searchValue).
|
Or("lastname LIKE ?", searchValue).
|
||||||
Or("email LIKE ?", searchValue).
|
Or("email LIKE ?", searchValue).
|
||||||
Preload("WebAuthnCredentialList").
|
Preload("WebAuthnCredentialList").
|
||||||
|
Preload("Authentications").
|
||||||
Find(&users).Error
|
Find(&users).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -885,7 +963,17 @@ func (r *SqlRepo) getOrCreateUser(ui *domain.ContextUserInfo, tx *gorm.DB, id do
|
|||||||
) {
|
) {
|
||||||
var user domain.User
|
var user domain.User
|
||||||
|
|
||||||
// userDefaults will be applied to newly created user records
|
result := tx.Model(&user).Preload("WebAuthnCredentialList").Preload("Authentications").Find(&user, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.Error == nil && result.RowsAffected > 0 {
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a new user record if no user record exists yet
|
||||||
userDefaults := domain.User{
|
userDefaults := domain.User{
|
||||||
BaseModel: domain.BaseModel{
|
BaseModel: domain.BaseModel{
|
||||||
CreatedBy: ui.UserId(),
|
CreatedBy: ui.UserId(),
|
||||||
@@ -894,16 +982,15 @@ func (r *SqlRepo) getOrCreateUser(ui *domain.ContextUserInfo, tx *gorm.DB, id do
|
|||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
},
|
},
|
||||||
Identifier: id,
|
Identifier: id,
|
||||||
Source: domain.UserSourceDatabase,
|
|
||||||
IsAdmin: false,
|
IsAdmin: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.Attrs(userDefaults).FirstOrCreate(&user, id).Error
|
err := tx.Create(&userDefaults).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &user, nil
|
return &userDefaults, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *domain.User) error {
|
func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *domain.User) error {
|
||||||
@@ -920,6 +1007,11 @@ func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *doma
|
|||||||
return fmt.Errorf("failed to update users webauthn credentials: %w", err)
|
return fmt.Errorf("failed to update users webauthn credentials: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = tx.Session(&gorm.Session{FullSaveAssociations: true}).Unscoped().Model(user).Association("Authentications").Unscoped().Replace(user.Authentications)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update users authentications: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
979
internal/adapters/wgcontroller/pfsense.go
Normal file
979
internal/adapters/wgcontroller/pfsense.go
Normal file
@@ -0,0 +1,979 @@
|
|||||||
|
package wgcontroller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
|
"github.com/h44z/wg-portal/internal/lowlevel"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PfsenseController implements the InterfaceController interface for pfSense firewalls.
|
||||||
|
// It uses the pfSense REST API (https://pfrest.org/) to manage WireGuard interfaces and peers.
|
||||||
|
// API endpoint paths and field names should be verified against the Swagger documentation:
|
||||||
|
// https://pfrest.org/api-docs/
|
||||||
|
|
||||||
|
type PfsenseController struct {
|
||||||
|
coreCfg *config.Config
|
||||||
|
cfg *config.BackendPfsense
|
||||||
|
|
||||||
|
client *lowlevel.PfsenseApiClient
|
||||||
|
|
||||||
|
// Add mutexes to prevent race conditions
|
||||||
|
interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*sync.Mutex
|
||||||
|
peerMutexes sync.Map // map[domain.PeerIdentifier]*sync.Mutex
|
||||||
|
coreMutex sync.Mutex // for updating the core configuration such as routing table or DNS settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPfsenseController(coreCfg *config.Config, cfg *config.BackendPfsense) (*PfsenseController, error) {
|
||||||
|
client, err := lowlevel.NewPfsenseApiClient(coreCfg, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create pfSense API client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PfsenseController{
|
||||||
|
coreCfg: coreCfg,
|
||||||
|
cfg: cfg,
|
||||||
|
|
||||||
|
client: client,
|
||||||
|
|
||||||
|
interfaceMutexes: sync.Map{},
|
||||||
|
peerMutexes: sync.Map{},
|
||||||
|
coreMutex: sync.Mutex{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) GetId() domain.InterfaceBackend {
|
||||||
|
return domain.InterfaceBackend(c.cfg.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getInterfaceMutex returns a mutex for the given interface to prevent concurrent modifications
|
||||||
|
func (c *PfsenseController) getInterfaceMutex(id domain.InterfaceIdentifier) *sync.Mutex {
|
||||||
|
mutex, _ := c.interfaceMutexes.LoadOrStore(id, &sync.Mutex{})
|
||||||
|
return mutex.(*sync.Mutex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPeerMutex returns a mutex for the given peer to prevent concurrent modifications
|
||||||
|
func (c *PfsenseController) getPeerMutex(id domain.PeerIdentifier) *sync.Mutex {
|
||||||
|
mutex, _ := c.peerMutexes.LoadOrStore(id, &sync.Mutex{})
|
||||||
|
return mutex.(*sync.Mutex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// region wireguard-related
|
||||||
|
|
||||||
|
func (c *PfsenseController) GetInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) {
|
||||||
|
// Query WireGuard tunnels from pfSense API
|
||||||
|
// Using pfSense REST API v2 endpoints: GET /api/v2/vpn/wireguard/tunnels
|
||||||
|
// Field names should be verified against Swagger docs: https://pfrest.org/api-docs/
|
||||||
|
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{})
|
||||||
|
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||||
|
return nil, fmt.Errorf("failed to query interfaces: %v", wgReply.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parallelize loading of interface details to speed up overall latency.
|
||||||
|
// Use a bounded semaphore to avoid overloading the pfSense device.
|
||||||
|
maxConcurrent := c.cfg.GetConcurrency()
|
||||||
|
sem := make(chan struct{}, maxConcurrent)
|
||||||
|
|
||||||
|
interfaces := make([]domain.PhysicalInterface, 0, len(wgReply.Data))
|
||||||
|
var mu sync.Mutex
|
||||||
|
var wgWait sync.WaitGroup
|
||||||
|
var firstErr error
|
||||||
|
ctx2, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for _, wgObj := range wgReply.Data {
|
||||||
|
wgWait.Add(1)
|
||||||
|
sem <- struct{}{} // block if more than maxConcurrent requests are processing
|
||||||
|
go func(wg lowlevel.GenericJsonObject) {
|
||||||
|
defer wgWait.Done()
|
||||||
|
defer func() { <-sem }() // read from the semaphore and make space for the next entry
|
||||||
|
if firstErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pi, err := c.loadInterfaceData(ctx2, wg)
|
||||||
|
if err != nil {
|
||||||
|
mu.Lock()
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
interfaces = append(interfaces, *pi)
|
||||||
|
mu.Unlock()
|
||||||
|
}(wgObj)
|
||||||
|
}
|
||||||
|
|
||||||
|
wgWait.Wait()
|
||||||
|
if firstErr != nil {
|
||||||
|
return nil, firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return interfaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (
|
||||||
|
*domain.PhysicalInterface,
|
||||||
|
error,
|
||||||
|
) {
|
||||||
|
// First, get the tunnel ID by querying by name
|
||||||
|
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{
|
||||||
|
Filters: map[string]string{
|
||||||
|
"name": string(id),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
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, fmt.Errorf("interface %s not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelId := wgReply.Data[0].GetString("id")
|
||||||
|
|
||||||
|
// Query the specific tunnel endpoint to get full details including addresses
|
||||||
|
// Endpoint: GET /api/v2/vpn/wireguard/tunnel?id={id}
|
||||||
|
if tunnelId != "" {
|
||||||
|
tunnelReply := c.client.Get(ctx, "/api/v2/vpn/wireguard/tunnel", &lowlevel.PfsenseRequestOptions{
|
||||||
|
Filters: map[string]string{
|
||||||
|
"id": tunnelId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if tunnelReply.Status == lowlevel.PfsenseApiStatusOk && tunnelReply.Data != nil {
|
||||||
|
// Use the detailed tunnel response which includes addresses
|
||||||
|
return c.loadInterfaceData(ctx, tunnelReply.Data)
|
||||||
|
}
|
||||||
|
// Fall back to list response if detail query fails
|
||||||
|
if c.cfg.Debug {
|
||||||
|
slog.Debug("failed to query detailed tunnel info, using list response", "interface", id, "tunnel_id", tunnelId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.loadInterfaceData(ctx, wgReply.Data[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) loadInterfaceData(
|
||||||
|
ctx context.Context,
|
||||||
|
wireGuardObj lowlevel.GenericJsonObject,
|
||||||
|
) (*domain.PhysicalInterface, error) {
|
||||||
|
deviceName := wireGuardObj.GetString("name")
|
||||||
|
deviceId := wireGuardObj.GetString("id")
|
||||||
|
|
||||||
|
// Extract addresses from the tunnel data
|
||||||
|
// The tunnel response may include an "addresses" array when queried via /tunnel?id={id}
|
||||||
|
addresses := c.extractAddresses(wireGuardObj, nil)
|
||||||
|
|
||||||
|
// If addresses weren't found in the tunnel object and we have a tunnel ID,
|
||||||
|
// query the specific tunnel endpoint to get full details including addresses
|
||||||
|
// Endpoint: GET /api/v2/vpn/wireguard/tunnel?id={id}
|
||||||
|
if len(addresses) == 0 && deviceId != "" {
|
||||||
|
tunnelReply := c.client.Get(ctx, "/api/v2/vpn/wireguard/tunnel", &lowlevel.PfsenseRequestOptions{
|
||||||
|
Filters: map[string]string{
|
||||||
|
"id": deviceId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if tunnelReply.Status == lowlevel.PfsenseApiStatusOk && tunnelReply.Data != nil {
|
||||||
|
// Extract addresses from the detailed tunnel response
|
||||||
|
parsedAddrs := c.extractAddresses(tunnelReply.Data, nil)
|
||||||
|
if len(parsedAddrs) > 0 {
|
||||||
|
addresses = parsedAddrs
|
||||||
|
if c.cfg.Debug {
|
||||||
|
slog.Debug("loaded addresses from detailed tunnel query", "interface", deviceName, "count", len(addresses))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interfaceModel, err := c.convertWireGuardInterface(wireGuardObj, nil, addresses)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("interface convert failed for %s: %w", deviceName, err)
|
||||||
|
}
|
||||||
|
return &interfaceModel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) extractAddresses(
|
||||||
|
wgObj lowlevel.GenericJsonObject,
|
||||||
|
ifaceObj lowlevel.GenericJsonObject,
|
||||||
|
) []domain.Cidr {
|
||||||
|
addresses := make([]domain.Cidr, 0)
|
||||||
|
|
||||||
|
// Try to get addresses from ifaceObj first
|
||||||
|
if ifaceObj != nil {
|
||||||
|
addrStr := ifaceObj.GetString("addresses")
|
||||||
|
if addrStr != "" {
|
||||||
|
// Addresses might be comma-separated or in an array
|
||||||
|
addrs, _ := domain.CidrsFromString(addrStr)
|
||||||
|
addresses = append(addresses, addrs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get addresses from wgObj - check if it's an array first
|
||||||
|
if len(addresses) == 0 {
|
||||||
|
if addressesValue, ok := wgObj["addresses"]; ok && addressesValue != nil {
|
||||||
|
if addressesArray, ok := addressesValue.([]any); ok {
|
||||||
|
// Parse addresses array (from /tunnel?id={id} response)
|
||||||
|
// Each object has "address" and "mask" fields
|
||||||
|
for _, addrItem := range addressesArray {
|
||||||
|
if addrObj, ok := addrItem.(map[string]any); ok {
|
||||||
|
address := ""
|
||||||
|
mask := 0
|
||||||
|
|
||||||
|
// Extract address
|
||||||
|
if addrVal, ok := addrObj["address"]; ok {
|
||||||
|
if addrStr, ok := addrVal.(string); ok {
|
||||||
|
address = addrStr
|
||||||
|
} else {
|
||||||
|
address = fmt.Sprintf("%v", addrVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract mask
|
||||||
|
if maskVal, ok := addrObj["mask"]; ok {
|
||||||
|
if maskInt, ok := maskVal.(int); ok {
|
||||||
|
mask = maskInt
|
||||||
|
} else if maskFloat, ok := maskVal.(float64); ok {
|
||||||
|
mask = int(maskFloat)
|
||||||
|
} else if maskStr, ok := maskVal.(string); ok {
|
||||||
|
if maskInt, err := strconv.Atoi(maskStr); err == nil {
|
||||||
|
mask = maskInt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to CIDR format
|
||||||
|
if address != "" && mask > 0 {
|
||||||
|
cidrStr := fmt.Sprintf("%s/%d", address, mask)
|
||||||
|
if cidr, err := domain.CidrFromString(cidrStr); err == nil {
|
||||||
|
addresses = append(addresses, cidr)
|
||||||
|
}
|
||||||
|
} else if address != "" {
|
||||||
|
// Try parsing as CIDR string directly
|
||||||
|
if cidr, err := domain.CidrFromString(address); err == nil {
|
||||||
|
addresses = append(addresses, cidr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if addrStr, ok := addressesValue.(string); ok {
|
||||||
|
// Fallback: try parsing as comma-separated string
|
||||||
|
addrs, _ := domain.CidrsFromString(addrStr)
|
||||||
|
addresses = append(addresses, addrs...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try as string field
|
||||||
|
addrStr := wgObj.GetString("addresses")
|
||||||
|
if addrStr != "" {
|
||||||
|
addrs, _ := domain.CidrsFromString(addrStr)
|
||||||
|
addresses = append(addresses, addrs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAddressArray parses an array of address objects from the pfSense API
|
||||||
|
// Each object has "address" and "mask" fields (similar to allowedips structure)
|
||||||
|
func (c *PfsenseController) parseAddressArray(addressArray []lowlevel.GenericJsonObject) []domain.Cidr {
|
||||||
|
addresses := make([]domain.Cidr, 0, len(addressArray))
|
||||||
|
|
||||||
|
for _, addrObj := range addressArray {
|
||||||
|
address := addrObj.GetString("address")
|
||||||
|
mask := addrObj.GetInt("mask")
|
||||||
|
|
||||||
|
if address != "" && mask > 0 {
|
||||||
|
cidrStr := fmt.Sprintf("%s/%d", address, mask)
|
||||||
|
if cidr, err := domain.CidrFromString(cidrStr); err == nil {
|
||||||
|
addresses = append(addresses, cidr)
|
||||||
|
}
|
||||||
|
} else if address != "" {
|
||||||
|
// Try parsing as CIDR string directly
|
||||||
|
if cidr, err := domain.CidrFromString(address); err == nil {
|
||||||
|
addresses = append(addresses, cidr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) convertWireGuardInterface(
|
||||||
|
wg, iface lowlevel.GenericJsonObject,
|
||||||
|
addresses []domain.Cidr,
|
||||||
|
) (
|
||||||
|
domain.PhysicalInterface,
|
||||||
|
error,
|
||||||
|
) {
|
||||||
|
// Map pfSense field names to our domain model
|
||||||
|
// Field names should be verified against the Swagger UI: https://pfrest.org/api-docs/
|
||||||
|
// The implementation attempts to handle both camelCase and kebab-case variations
|
||||||
|
privateKey := wg.GetString("privatekey")
|
||||||
|
if privateKey == "" {
|
||||||
|
privateKey = wg.GetString("private-key")
|
||||||
|
}
|
||||||
|
publicKey := wg.GetString("publickey")
|
||||||
|
if publicKey == "" {
|
||||||
|
publicKey = wg.GetString("public-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
listenPort := wg.GetInt("listenport")
|
||||||
|
if listenPort == 0 {
|
||||||
|
listenPort = wg.GetInt("listen-port")
|
||||||
|
}
|
||||||
|
|
||||||
|
mtu := wg.GetInt("mtu")
|
||||||
|
running := wg.GetBool("running")
|
||||||
|
disabled := wg.GetBool("disabled")
|
||||||
|
|
||||||
|
// TODO: Interface statistics (rx/tx bytes) are not currently supported
|
||||||
|
// by the pfSense REST API. This functionality is reserved for future implementation.
|
||||||
|
var rxBytes, txBytes uint64
|
||||||
|
|
||||||
|
pi := domain.PhysicalInterface{
|
||||||
|
Identifier: domain.InterfaceIdentifier(wg.GetString("name")),
|
||||||
|
KeyPair: domain.KeyPair{
|
||||||
|
PrivateKey: privateKey,
|
||||||
|
PublicKey: publicKey,
|
||||||
|
},
|
||||||
|
ListenPort: listenPort,
|
||||||
|
Addresses: addresses,
|
||||||
|
Mtu: mtu,
|
||||||
|
FirewallMark: 0,
|
||||||
|
DeviceUp: running && !disabled,
|
||||||
|
ImportSource: domain.ControllerTypePfsense,
|
||||||
|
DeviceType: domain.ControllerTypePfsense,
|
||||||
|
BytesUpload: txBytes,
|
||||||
|
BytesDownload: rxBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract description - pfSense API uses "descr" field
|
||||||
|
description := wg.GetString("descr")
|
||||||
|
if description == "" {
|
||||||
|
description = wg.GetString("description")
|
||||||
|
}
|
||||||
|
if description == "" {
|
||||||
|
description = wg.GetString("comment")
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.SetExtras(domain.PfsenseInterfaceExtras{
|
||||||
|
Id: wg.GetString("id"),
|
||||||
|
Comment: description,
|
||||||
|
Disabled: disabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
return pi, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) GetPeers(ctx context.Context, deviceId domain.InterfaceIdentifier) (
|
||||||
|
[]domain.PhysicalPeer,
|
||||||
|
error,
|
||||||
|
) {
|
||||||
|
// Query all peers and filter by interface client-side
|
||||||
|
// Using pfSense REST API v2 endpoints (https://pfrest.org/)
|
||||||
|
// The API uses query parameters like ?id=0 for specific items, but we need to filter
|
||||||
|
// by interface (tun field), so we fetch all peers and filter client-side
|
||||||
|
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{})
|
||||||
|
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||||
|
return nil, fmt.Errorf("failed to query peers for %s: %v", deviceId, wgReply.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wgReply.Data) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter peers client-side by checking the "tun" field in each peer
|
||||||
|
// pfSense peer responses use "tun" field to indicate which tunnel/interface the peer belongs to
|
||||||
|
peers := make([]domain.PhysicalPeer, 0, len(wgReply.Data))
|
||||||
|
for _, peer := range wgReply.Data {
|
||||||
|
// Check if this peer belongs to the requested interface
|
||||||
|
// pfSense uses "tun" field with the interface name (e.g., "tun_wg0")
|
||||||
|
peerTun := peer.GetString("tun")
|
||||||
|
if peerTun == "" {
|
||||||
|
// Try alternative field names as fallback
|
||||||
|
peerTun = peer.GetString("interface")
|
||||||
|
if peerTun == "" {
|
||||||
|
peerTun = peer.GetString("tunnel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include peers that match the requested interface name
|
||||||
|
if peerTun != string(deviceId) {
|
||||||
|
if c.cfg.Debug {
|
||||||
|
slog.Debug("skipping peer - interface mismatch",
|
||||||
|
"peer", peer.GetString("name"),
|
||||||
|
"peer_tun", peerTun,
|
||||||
|
"requested_interface", deviceId,
|
||||||
|
"peer_id", peer.GetString("id"))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use peer data directly from the list response
|
||||||
|
peerModel, err := c.convertWireGuardPeer(peer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("peer convert failed for %v: %w", peer.GetString("name"), err)
|
||||||
|
}
|
||||||
|
peers = append(peers, peerModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.cfg.Debug {
|
||||||
|
slog.Debug("filtered peers for interface",
|
||||||
|
"interface", deviceId,
|
||||||
|
"total_peers_from_api", len(wgReply.Data),
|
||||||
|
"filtered_peers", len(peers))
|
||||||
|
}
|
||||||
|
|
||||||
|
return peers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) convertWireGuardPeer(peer lowlevel.GenericJsonObject) (
|
||||||
|
domain.PhysicalPeer,
|
||||||
|
error,
|
||||||
|
) {
|
||||||
|
publicKey := peer.GetString("publickey")
|
||||||
|
if publicKey == "" {
|
||||||
|
publicKey = peer.GetString("public-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey := peer.GetString("privatekey")
|
||||||
|
if privateKey == "" {
|
||||||
|
privateKey = peer.GetString("private-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
presharedKey := peer.GetString("presharedkey")
|
||||||
|
if presharedKey == "" {
|
||||||
|
presharedKey = peer.GetString("preshared-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// pfSense returns allowedips as an array of objects with "address" and "mask" fields
|
||||||
|
// Example: [{"address": "10.1.2.3", "mask": 32, ...}, ...]
|
||||||
|
var allowedAddresses []domain.Cidr
|
||||||
|
if allowedIPsValue, ok := peer["allowedips"]; ok {
|
||||||
|
if allowedIPsArray, ok := allowedIPsValue.([]any); ok {
|
||||||
|
// Parse array of objects
|
||||||
|
for _, item := range allowedIPsArray {
|
||||||
|
if itemObj, ok := item.(map[string]any); ok {
|
||||||
|
address := ""
|
||||||
|
mask := 0
|
||||||
|
|
||||||
|
// Extract address
|
||||||
|
if addrVal, ok := itemObj["address"]; ok {
|
||||||
|
if addrStr, ok := addrVal.(string); ok {
|
||||||
|
address = addrStr
|
||||||
|
} else {
|
||||||
|
address = fmt.Sprintf("%v", addrVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract mask
|
||||||
|
if maskVal, ok := itemObj["mask"]; ok {
|
||||||
|
if maskInt, ok := maskVal.(int); ok {
|
||||||
|
mask = maskInt
|
||||||
|
} else if maskFloat, ok := maskVal.(float64); ok {
|
||||||
|
mask = int(maskFloat)
|
||||||
|
} else if maskStr, ok := maskVal.(string); ok {
|
||||||
|
if maskInt, err := strconv.Atoi(maskStr); err == nil {
|
||||||
|
mask = maskInt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to CIDR format (e.g., "10.1.2.3/32")
|
||||||
|
if address != "" && mask > 0 {
|
||||||
|
cidrStr := fmt.Sprintf("%s/%d", address, mask)
|
||||||
|
if cidr, err := domain.CidrFromString(cidrStr); err == nil {
|
||||||
|
allowedAddresses = append(allowedAddresses, cidr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if allowedIPsStr, ok := allowedIPsValue.(string); ok {
|
||||||
|
// Fallback: try parsing as comma-separated string
|
||||||
|
allowedAddresses, _ = domain.CidrsFromString(allowedIPsStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to string parsing if array parsing didn't work
|
||||||
|
if len(allowedAddresses) == 0 {
|
||||||
|
allowedIPsStr := peer.GetString("allowedips")
|
||||||
|
if allowedIPsStr == "" {
|
||||||
|
allowedIPsStr = peer.GetString("allowed-ips")
|
||||||
|
}
|
||||||
|
if allowedIPsStr != "" {
|
||||||
|
allowedAddresses, _ = domain.CidrsFromString(allowedIPsStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := peer.GetString("endpoint")
|
||||||
|
port := peer.GetString("port")
|
||||||
|
|
||||||
|
// Combine endpoint and port if both are available
|
||||||
|
if endpoint != "" && port != "" {
|
||||||
|
// Check if endpoint already contains a port
|
||||||
|
if !strings.Contains(endpoint, ":") {
|
||||||
|
endpoint = fmt.Sprintf("%s:%s", endpoint, port)
|
||||||
|
}
|
||||||
|
} else if endpoint == "" && port != "" {
|
||||||
|
// If only port is available, we can't construct a full endpoint
|
||||||
|
// This might be used with the interface's listenport
|
||||||
|
}
|
||||||
|
|
||||||
|
keepAliveSeconds := 0
|
||||||
|
keepAliveStr := peer.GetString("persistentkeepalive")
|
||||||
|
if keepAliveStr == "" {
|
||||||
|
keepAliveStr = peer.GetString("persistent-keepalive")
|
||||||
|
}
|
||||||
|
if keepAliveStr != "" {
|
||||||
|
duration, err := time.ParseDuration(keepAliveStr)
|
||||||
|
if err == nil {
|
||||||
|
keepAliveSeconds = int(duration.Seconds())
|
||||||
|
} else {
|
||||||
|
// Try parsing as integer (seconds)
|
||||||
|
if secs, err := strconv.Atoi(keepAliveStr); err == nil {
|
||||||
|
keepAliveSeconds = secs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Peer statistics (last handshake, rx/tx bytes) are not currently supported
|
||||||
|
// by the pfSense REST API. This functionality is reserved for future implementation
|
||||||
|
// when the API adds support for these fields.
|
||||||
|
// See: https://github.com/jaredhendrickson13/pfsense-api/issues (issue opened by user)
|
||||||
|
//
|
||||||
|
// When supported, extract fields like:
|
||||||
|
// - lastHandshake: peer.GetString("lasthandshake") or peer.GetString("last-handshake")
|
||||||
|
// - rxBytes: peer.GetInt("rxbytes") or peer.GetInt("rx-bytes")
|
||||||
|
// - txBytes: peer.GetInt("txbytes") or peer.GetInt("tx-bytes")
|
||||||
|
lastHandshakeTime := time.Time{}
|
||||||
|
rxBytes := uint64(0)
|
||||||
|
txBytes := uint64(0)
|
||||||
|
|
||||||
|
peerModel := domain.PhysicalPeer{
|
||||||
|
Identifier: domain.PeerIdentifier(publicKey),
|
||||||
|
Endpoint: endpoint,
|
||||||
|
AllowedIPs: allowedAddresses,
|
||||||
|
KeyPair: domain.KeyPair{
|
||||||
|
PublicKey: publicKey,
|
||||||
|
PrivateKey: privateKey,
|
||||||
|
},
|
||||||
|
PresharedKey: domain.PreSharedKey(presharedKey),
|
||||||
|
PersistentKeepalive: keepAliveSeconds,
|
||||||
|
LastHandshake: lastHandshakeTime,
|
||||||
|
ProtocolVersion: 0, // pfSense may not expose protocol version
|
||||||
|
BytesUpload: txBytes,
|
||||||
|
BytesDownload: rxBytes,
|
||||||
|
ImportSource: domain.ControllerTypePfsense,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract description/name - pfSense API uses "descr" field
|
||||||
|
description := peer.GetString("descr")
|
||||||
|
if description == "" {
|
||||||
|
description = peer.GetString("description")
|
||||||
|
}
|
||||||
|
if description == "" {
|
||||||
|
description = peer.GetString("comment")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract name - pfSense API may use "name" or "descr"
|
||||||
|
name := peer.GetString("name")
|
||||||
|
if name == "" {
|
||||||
|
name = peer.GetString("descr")
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = description // fallback to description if name is not available
|
||||||
|
}
|
||||||
|
|
||||||
|
peerModel.SetExtras(domain.PfsensePeerExtras{
|
||||||
|
Id: peer.GetString("id"),
|
||||||
|
Name: name,
|
||||||
|
Comment: description,
|
||||||
|
Disabled: peer.GetBool("disabled"),
|
||||||
|
ClientEndpoint: "", // pfSense may handle this differently
|
||||||
|
ClientAddress: "", // pfSense may handle this differently
|
||||||
|
ClientDns: "", // pfSense may handle this differently
|
||||||
|
ClientKeepalive: 0, // pfSense may handle this differently
|
||||||
|
})
|
||||||
|
|
||||||
|
return peerModel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) SaveInterface(
|
||||||
|
ctx context.Context,
|
||||||
|
id domain.InterfaceIdentifier,
|
||||||
|
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
|
||||||
|
) error {
|
||||||
|
// Lock the interface to prevent concurrent modifications
|
||||||
|
mutex := c.getInterfaceMutex(id)
|
||||||
|
mutex.Lock()
|
||||||
|
defer mutex.Unlock()
|
||||||
|
|
||||||
|
physicalInterface, err := c.getOrCreateInterface(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceId := ""
|
||||||
|
if physicalInterface.GetExtras() != nil {
|
||||||
|
if extras, ok := physicalInterface.GetExtras().(domain.PfsenseInterfaceExtras); ok {
|
||||||
|
deviceId = extras.Id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if updateFunc != nil {
|
||||||
|
physicalInterface, err = updateFunc(physicalInterface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if deviceId != "" {
|
||||||
|
// Ensure the ID is preserved
|
||||||
|
if extras, ok := physicalInterface.GetExtras().(domain.PfsenseInterfaceExtras); ok {
|
||||||
|
extras.Id = deviceId
|
||||||
|
physicalInterface.SetExtras(extras)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.updateInterface(ctx, physicalInterface); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) getOrCreateInterface(
|
||||||
|
ctx context.Context,
|
||||||
|
id domain.InterfaceIdentifier,
|
||||||
|
) (*domain.PhysicalInterface, error) {
|
||||||
|
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{
|
||||||
|
Filters: map[string]string{
|
||||||
|
"name": string(id),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if wgReply.Status == lowlevel.PfsenseApiStatusOk && len(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 {
|
||||||
|
extras := pi.GetExtras().(domain.PfsenseInterfaceExtras)
|
||||||
|
interfaceId := extras.Id
|
||||||
|
|
||||||
|
payload := lowlevel.GenericJsonObject{
|
||||||
|
"name": string(pi.Identifier),
|
||||||
|
"description": extras.Comment,
|
||||||
|
"mtu": strconv.Itoa(pi.Mtu),
|
||||||
|
"listenport": strconv.Itoa(pi.ListenPort),
|
||||||
|
"privatekey": pi.KeyPair.PrivateKey,
|
||||||
|
"disabled": strconv.FormatBool(!pi.DeviceUp),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add addresses if present
|
||||||
|
if len(pi.Addresses) > 0 {
|
||||||
|
addresses := make([]string, 0, len(pi.Addresses))
|
||||||
|
for _, addr := range pi.Addresses {
|
||||||
|
addresses = append(addresses, addr.String())
|
||||||
|
}
|
||||||
|
payload["addresses"] = strings.Join(addresses, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actual endpoint: PATCH /api/v2/vpn/wireguard/tunnel?id={id}
|
||||||
|
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/tunnel?id="+interfaceId, payload)
|
||||||
|
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||||
|
return fmt.Errorf("failed to update interface %s: %v", pi.Identifier, wgReply.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error {
|
||||||
|
// Lock the interface to prevent concurrent modifications
|
||||||
|
mutex := c.getInterfaceMutex(id)
|
||||||
|
mutex.Lock()
|
||||||
|
defer mutex.Unlock()
|
||||||
|
|
||||||
|
// Find the tunnel ID
|
||||||
|
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{
|
||||||
|
Filters: map[string]string{
|
||||||
|
"name": string(id),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||||
|
return fmt.Errorf("unable to find WireGuard tunnel %s: %v", id, wgReply.Error)
|
||||||
|
}
|
||||||
|
if len(wgReply.Data) == 0 {
|
||||||
|
return nil // tunnel does not exist, nothing to delete
|
||||||
|
}
|
||||||
|
|
||||||
|
interfaceId := wgReply.Data[0].GetString("id")
|
||||||
|
// Actual endpoint: DELETE /api/v2/vpn/wireguard/tunnel?id={id}
|
||||||
|
deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/tunnel?id="+interfaceId)
|
||||||
|
if deleteReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||||
|
return fmt.Errorf("failed to delete WireGuard interface %s: %v", id, deleteReply.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) SavePeer(
|
||||||
|
ctx context.Context,
|
||||||
|
deviceId domain.InterfaceIdentifier,
|
||||||
|
id domain.PeerIdentifier,
|
||||||
|
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
|
||||||
|
) error {
|
||||||
|
// Lock the peer to prevent concurrent modifications
|
||||||
|
mutex := c.getPeerMutex(id)
|
||||||
|
mutex.Lock()
|
||||||
|
defer mutex.Unlock()
|
||||||
|
|
||||||
|
physicalPeer, err := c.getOrCreatePeer(ctx, deviceId, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
peerId := ""
|
||||||
|
if physicalPeer.GetExtras() != nil {
|
||||||
|
if extras, ok := physicalPeer.GetExtras().(domain.PfsensePeerExtras); ok {
|
||||||
|
peerId = extras.Id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
physicalPeer, err = updateFunc(physicalPeer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if peerId != "" {
|
||||||
|
// Ensure the ID is preserved
|
||||||
|
if extras, ok := physicalPeer.GetExtras().(domain.PfsensePeerExtras); ok {
|
||||||
|
extras.Id = peerId
|
||||||
|
physicalPeer.SetExtras(extras)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.updatePeer(ctx, deviceId, physicalPeer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) getOrCreatePeer(
|
||||||
|
ctx context.Context,
|
||||||
|
deviceId domain.InterfaceIdentifier,
|
||||||
|
id domain.PeerIdentifier,
|
||||||
|
) (*domain.PhysicalPeer, error) {
|
||||||
|
// Query for peer by publickey and interface (tun field)
|
||||||
|
// The API uses query parameters like ?publickey=...&tun=...
|
||||||
|
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{
|
||||||
|
Filters: map[string]string{
|
||||||
|
"publickey": string(id),
|
||||||
|
"tun": string(deviceId), // Use "tun" field name as that's what the API uses
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if wgReply.Status == lowlevel.PfsenseApiStatusOk && len(wgReply.Data) > 0 {
|
||||||
|
slog.Debug("found existing pfSense peer", "peer", id, "interface", deviceId)
|
||||||
|
existingPeer, err := c.convertWireGuardPeer(wgReply.Data[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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(
|
||||||
|
ctx context.Context,
|
||||||
|
deviceId domain.InterfaceIdentifier,
|
||||||
|
pp *domain.PhysicalPeer,
|
||||||
|
) error {
|
||||||
|
extras := pp.GetExtras().(domain.PfsensePeerExtras)
|
||||||
|
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{
|
||||||
|
"name": extras.Name,
|
||||||
|
"description": extras.Comment,
|
||||||
|
"presharedkey": string(pp.PresharedKey),
|
||||||
|
"publickey": pp.KeyPair.PublicKey,
|
||||||
|
"privatekey": pp.KeyPair.PrivateKey,
|
||||||
|
"persistentkeepalive": strconv.Itoa(pp.PersistentKeepalive),
|
||||||
|
"disabled": strconv.FormatBool(extras.Disabled),
|
||||||
|
"allowedips": allowedIPsStr,
|
||||||
|
}
|
||||||
|
|
||||||
|
if pp.Endpoint != "" {
|
||||||
|
payload["endpoint"] = pp.Endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actual endpoint: PATCH /api/v2/vpn/wireguard/peer?id={id}
|
||||||
|
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/peer?id="+peerId, payload)
|
||||||
|
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||||
|
return fmt.Errorf("failed to update peer %s on interface %s: %v", pp.Identifier, deviceId, wgReply.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if extras.Disabled {
|
||||||
|
slog.Debug("successfully disabled pfSense peer", "peer", pp.Identifier, "interface", deviceId)
|
||||||
|
} else {
|
||||||
|
slog.Debug("successfully updated pfSense peer", "peer", pp.Identifier, "interface", deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) DeletePeer(
|
||||||
|
ctx context.Context,
|
||||||
|
deviceId domain.InterfaceIdentifier,
|
||||||
|
id domain.PeerIdentifier,
|
||||||
|
) error {
|
||||||
|
// Lock the peer to prevent concurrent modifications
|
||||||
|
mutex := c.getPeerMutex(id)
|
||||||
|
mutex.Lock()
|
||||||
|
defer mutex.Unlock()
|
||||||
|
|
||||||
|
// Query for peer by publickey and interface (tun field)
|
||||||
|
// The API uses query parameters like ?publickey=...&tun=...
|
||||||
|
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{
|
||||||
|
Filters: map[string]string{
|
||||||
|
"publickey": string(id),
|
||||||
|
"tun": string(deviceId), // Use "tun" field name as that's what the API uses
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||||
|
return fmt.Errorf("unable to find WireGuard peer %s for interface %s: %v", id, deviceId, wgReply.Error)
|
||||||
|
}
|
||||||
|
if len(wgReply.Data) == 0 {
|
||||||
|
return nil // peer does not exist, nothing to delete
|
||||||
|
}
|
||||||
|
|
||||||
|
peerId := wgReply.Data[0].GetString("id")
|
||||||
|
// Actual endpoint: DELETE /api/v2/vpn/wireguard/peer?id={id}
|
||||||
|
deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/peer?id="+peerId)
|
||||||
|
if deleteReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||||
|
return fmt.Errorf("failed to delete WireGuard peer %s for interface %s: %v", id, deviceId, deleteReply.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion wireguard-related
|
||||||
|
|
||||||
|
// region wg-quick-related
|
||||||
|
|
||||||
|
func (c *PfsenseController) ExecuteInterfaceHook(
|
||||||
|
_ context.Context,
|
||||||
|
_ domain.InterfaceIdentifier,
|
||||||
|
_ string,
|
||||||
|
) error {
|
||||||
|
// TODO implement me
|
||||||
|
slog.Error("interface hooks are not yet supported for pfSense backends, please open an issue on GitHub")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) SetDNS(
|
||||||
|
ctx context.Context,
|
||||||
|
_ domain.InterfaceIdentifier,
|
||||||
|
dnsStr, _ string,
|
||||||
|
) error {
|
||||||
|
// Lock the interface to prevent concurrent modifications
|
||||||
|
c.coreMutex.Lock()
|
||||||
|
defer c.coreMutex.Unlock()
|
||||||
|
|
||||||
|
// pfSense DNS configuration is typically managed at the system level
|
||||||
|
// This may need to be implemented based on pfSense API capabilities
|
||||||
|
slog.Warn("DNS setting is not yet fully supported for pfSense backends")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) UnsetDNS(
|
||||||
|
ctx context.Context,
|
||||||
|
_ domain.InterfaceIdentifier,
|
||||||
|
dnsStr, _ string,
|
||||||
|
) error {
|
||||||
|
// Lock the interface to prevent concurrent modifications
|
||||||
|
c.coreMutex.Lock()
|
||||||
|
defer c.coreMutex.Unlock()
|
||||||
|
|
||||||
|
// pfSense DNS configuration is typically managed at the system level
|
||||||
|
slog.Warn("DNS unsetting is not yet fully supported for pfSense backends")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion wg-quick-related
|
||||||
|
|
||||||
|
// region routing-related
|
||||||
|
|
||||||
|
func (c *PfsenseController) SetRoutes(_ context.Context, info domain.RoutingTableInfo) error {
|
||||||
|
// pfSense routing is typically managed through the firewall rules and routing tables
|
||||||
|
// This may need to be implemented based on pfSense API capabilities
|
||||||
|
slog.Warn("route setting is not yet fully supported for pfSense backends")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PfsenseController) RemoveRoutes(_ context.Context, info domain.RoutingTableInfo) error {
|
||||||
|
// pfSense routing is typically managed through the firewall rules and routing tables
|
||||||
|
slog.Warn("route removal is not yet fully supported for pfSense backends")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion routing-related
|
||||||
|
|
||||||
|
// region statistics-related
|
||||||
|
|
||||||
|
func (c *PfsenseController) PingAddresses(
|
||||||
|
ctx context.Context,
|
||||||
|
addr string,
|
||||||
|
) (*domain.PingerResult, error) {
|
||||||
|
// Use pfSense API to ping if available, otherwise return error
|
||||||
|
// This may need to be implemented based on pfSense API capabilities
|
||||||
|
return nil, fmt.Errorf("ping functionality is not yet implemented for pfSense backends")
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion statistics-related
|
||||||
|
|
||||||
@@ -800,6 +800,126 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/peer/bulk-delete": {
|
||||||
|
"post": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Peer"
|
||||||
|
],
|
||||||
|
"summary": "Bulk delete selected peers.",
|
||||||
|
"operationId": "peers_handleBulkDelete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "A list of peer identifiers to delete",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No content if deletion was successful"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/peer/bulk-disable": {
|
||||||
|
"post": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Peer"
|
||||||
|
],
|
||||||
|
"summary": "Bulk disable selected peers.",
|
||||||
|
"operationId": "peers_handleBulkDisable",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "A list of peer identifiers to disable",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No content if action was successful"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/peer/bulk-enable": {
|
||||||
|
"post": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Peer"
|
||||||
|
],
|
||||||
|
"summary": "Bulk enable selected peers.",
|
||||||
|
"operationId": "peers_handleBulkEnable",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "A list of peer identifiers to enable",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No content if action was successful"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/peer/config-mail": {
|
"/peer/config-mail": {
|
||||||
"post": {
|
"post": {
|
||||||
"produces": [
|
"produces": [
|
||||||
@@ -1324,6 +1444,206 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/user/bulk-delete": {
|
||||||
|
"post": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
],
|
||||||
|
"summary": "Bulk delete selected users.",
|
||||||
|
"operationId": "users_handleBulkDelete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "A list of user identifiers to delete",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No content if deletion was successful"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/user/bulk-disable": {
|
||||||
|
"post": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
],
|
||||||
|
"summary": "Bulk disable selected users.",
|
||||||
|
"operationId": "users_handleBulkDisable",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "A list of user identifiers to disable",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No content if action was successful"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/user/bulk-enable": {
|
||||||
|
"post": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
],
|
||||||
|
"summary": "Bulk enable selected users.",
|
||||||
|
"operationId": "users_handleBulkEnable",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "A list of user identifiers to enable",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No content if action was successful"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/user/bulk-lock": {
|
||||||
|
"post": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
],
|
||||||
|
"summary": "Bulk lock selected users.",
|
||||||
|
"operationId": "users_handleBulkLock",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "A list of user identifiers to lock",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No content if action was successful"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/user/bulk-unlock": {
|
||||||
|
"post": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
],
|
||||||
|
"summary": "Bulk unlock selected users.",
|
||||||
|
"operationId": "users_handleBulkUnlock",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "A list of user identifiers to unlock",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No content if action was successful"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/user/new": {
|
"/user/new": {
|
||||||
"post": {
|
"post": {
|
||||||
"produces": [
|
"produces": [
|
||||||
@@ -1550,6 +1870,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/user/{id}/change-password": {
|
||||||
|
"post": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
],
|
||||||
|
"summary": "Change the password for the given user.",
|
||||||
|
"operationId": "users_handleChangePasswordPost",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.User"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/user/{id}/interfaces": {
|
"/user/{id}/interfaces": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
@@ -1705,6 +2057,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"model.BulkPeerRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"Identifiers"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Identifiers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Reason": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"model.ConfigOption-array_string": {
|
"model.ConfigOption-array_string": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -2159,6 +2528,10 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"UserDisplayName": {
|
||||||
|
"description": "the owner display name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"UserIdentifier": {
|
"UserIdentifier": {
|
||||||
"description": "the owner",
|
"description": "the owner",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2303,6 +2676,12 @@
|
|||||||
"ApiTokenCreated": {
|
"ApiTokenCreated": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"AuthSources": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Department": {
|
"Department": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -2346,14 +2725,11 @@
|
|||||||
"PeerCount": {
|
"PeerCount": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"PersistLocalChanges": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"Phone": {
|
"Phone": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
|
||||||
"ProviderName": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"Source": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ definitions:
|
|||||||
Timestamp:
|
Timestamp:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
model.BulkPeerRequest:
|
||||||
|
properties:
|
||||||
|
Identifiers:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
Reason:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- Identifiers
|
||||||
|
type: object
|
||||||
model.ConfigOption-array_string:
|
model.ConfigOption-array_string:
|
||||||
properties:
|
properties:
|
||||||
Overridable:
|
Overridable:
|
||||||
@@ -322,6 +333,9 @@ definitions:
|
|||||||
allOf:
|
allOf:
|
||||||
- $ref: '#/definitions/model.ConfigOption-string'
|
- $ref: '#/definitions/model.ConfigOption-string'
|
||||||
description: the routing table
|
description: the routing table
|
||||||
|
UserDisplayName:
|
||||||
|
description: the owner display name
|
||||||
|
type: string
|
||||||
UserIdentifier:
|
UserIdentifier:
|
||||||
description: the owner
|
description: the owner
|
||||||
type: string
|
type: string
|
||||||
@@ -417,6 +431,10 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
ApiTokenCreated:
|
ApiTokenCreated:
|
||||||
type: string
|
type: string
|
||||||
|
AuthSources:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
Department:
|
Department:
|
||||||
type: string
|
type: string
|
||||||
Disabled:
|
Disabled:
|
||||||
@@ -447,12 +465,10 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
PeerCount:
|
PeerCount:
|
||||||
type: integer
|
type: integer
|
||||||
|
PersistLocalChanges:
|
||||||
|
type: boolean
|
||||||
Phone:
|
Phone:
|
||||||
type: string
|
type: string
|
||||||
ProviderName:
|
|
||||||
type: string
|
|
||||||
Source:
|
|
||||||
type: string
|
|
||||||
type: object
|
type: object
|
||||||
model.WebAuthnCredentialRequest:
|
model.WebAuthnCredentialRequest:
|
||||||
properties:
|
properties:
|
||||||
@@ -1077,6 +1093,84 @@ paths:
|
|||||||
summary: Update the given peer record.
|
summary: Update the given peer record.
|
||||||
tags:
|
tags:
|
||||||
- Peer
|
- Peer
|
||||||
|
/peer/bulk-delete:
|
||||||
|
post:
|
||||||
|
operationId: peers_handleBulkDelete
|
||||||
|
parameters:
|
||||||
|
- description: A list of peer identifiers to delete
|
||||||
|
in: body
|
||||||
|
name: request
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.BulkPeerRequest'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: No content if deletion was successful
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
summary: Bulk delete selected peers.
|
||||||
|
tags:
|
||||||
|
- Peer
|
||||||
|
/peer/bulk-disable:
|
||||||
|
post:
|
||||||
|
operationId: peers_handleBulkDisable
|
||||||
|
parameters:
|
||||||
|
- description: A list of peer identifiers to disable
|
||||||
|
in: body
|
||||||
|
name: request
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.BulkPeerRequest'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: No content if action was successful
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
summary: Bulk disable selected peers.
|
||||||
|
tags:
|
||||||
|
- Peer
|
||||||
|
/peer/bulk-enable:
|
||||||
|
post:
|
||||||
|
operationId: peers_handleBulkEnable
|
||||||
|
parameters:
|
||||||
|
- description: A list of peer identifiers to enable
|
||||||
|
in: body
|
||||||
|
name: request
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.BulkPeerRequest'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: No content if action was successful
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
summary: Bulk enable selected peers.
|
||||||
|
tags:
|
||||||
|
- Peer
|
||||||
/peer/config-mail:
|
/peer/config-mail:
|
||||||
post:
|
post:
|
||||||
operationId: peers_handleEmailPost
|
operationId: peers_handleEmailPost
|
||||||
@@ -1442,6 +1536,27 @@ paths:
|
|||||||
summary: Enable the REST API for the given user.
|
summary: Enable the REST API for the given user.
|
||||||
tags:
|
tags:
|
||||||
- Users
|
- Users
|
||||||
|
/user/{id}/change-password:
|
||||||
|
post:
|
||||||
|
operationId: users_handleChangePasswordPost
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.User'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
summary: Change the password for the given user.
|
||||||
|
tags:
|
||||||
|
- Users
|
||||||
/user/{id}/interfaces:
|
/user/{id}/interfaces:
|
||||||
get:
|
get:
|
||||||
operationId: users_handleInterfacesGet
|
operationId: users_handleInterfacesGet
|
||||||
@@ -1547,6 +1662,136 @@ paths:
|
|||||||
summary: Get all user records.
|
summary: Get all user records.
|
||||||
tags:
|
tags:
|
||||||
- Users
|
- Users
|
||||||
|
/user/bulk-delete:
|
||||||
|
post:
|
||||||
|
operationId: users_handleBulkDelete
|
||||||
|
parameters:
|
||||||
|
- description: A list of user identifiers to delete
|
||||||
|
in: body
|
||||||
|
name: request
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.BulkPeerRequest'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: No content if deletion was successful
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
summary: Bulk delete selected users.
|
||||||
|
tags:
|
||||||
|
- Users
|
||||||
|
/user/bulk-disable:
|
||||||
|
post:
|
||||||
|
operationId: users_handleBulkDisable
|
||||||
|
parameters:
|
||||||
|
- description: A list of user identifiers to disable
|
||||||
|
in: body
|
||||||
|
name: request
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.BulkPeerRequest'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: No content if action was successful
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
summary: Bulk disable selected users.
|
||||||
|
tags:
|
||||||
|
- Users
|
||||||
|
/user/bulk-enable:
|
||||||
|
post:
|
||||||
|
operationId: users_handleBulkEnable
|
||||||
|
parameters:
|
||||||
|
- description: A list of user identifiers to enable
|
||||||
|
in: body
|
||||||
|
name: request
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.BulkPeerRequest'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: No content if action was successful
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
summary: Bulk enable selected users.
|
||||||
|
tags:
|
||||||
|
- Users
|
||||||
|
/user/bulk-lock:
|
||||||
|
post:
|
||||||
|
operationId: users_handleBulkLock
|
||||||
|
parameters:
|
||||||
|
- description: A list of user identifiers to lock
|
||||||
|
in: body
|
||||||
|
name: request
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.BulkPeerRequest'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: No content if action was successful
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
summary: Bulk lock selected users.
|
||||||
|
tags:
|
||||||
|
- Users
|
||||||
|
/user/bulk-unlock:
|
||||||
|
post:
|
||||||
|
operationId: users_handleBulkUnlock
|
||||||
|
parameters:
|
||||||
|
- description: A list of user identifiers to unlock
|
||||||
|
in: body
|
||||||
|
name: request
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.BulkPeerRequest'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: No content if action was successful
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Error'
|
||||||
|
summary: Bulk unlock selected users.
|
||||||
|
tags:
|
||||||
|
- Users
|
||||||
/user/new:
|
/user/new:
|
||||||
post:
|
post:
|
||||||
operationId: users_handleCreatePost
|
operationId: users_handleCreatePost
|
||||||
|
|||||||
@@ -17,11 +17,6 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"/interface/all": {
|
"/interface/all": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"BasicAuth": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -52,16 +47,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/interface/by-id/{id}": {
|
|
||||||
"get": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/interface/by-id/{id}": {
|
||||||
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -110,14 +105,14 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
"description": "This endpoint updates an existing interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).",
|
"description": "This endpoint updates an existing interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -182,14 +177,14 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -241,16 +236,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/interface/new": {
|
|
||||||
"post": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/interface/new": {
|
||||||
|
"post": {
|
||||||
"description": "This endpoint creates a new interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).",
|
"description": "This endpoint creates a new interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -308,16 +303,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/interface/prepare": {
|
|
||||||
"get": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/interface/prepare": {
|
||||||
|
"get": {
|
||||||
"description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).",
|
"description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -352,16 +347,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/metrics/by-interface/{id}": {
|
|
||||||
"get": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/metrics/by-interface/{id}": {
|
||||||
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -410,16 +405,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/metrics/by-peer/{id}": {
|
|
||||||
"get": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/metrics/by-peer/{id}": {
|
||||||
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -468,16 +463,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/metrics/by-user/{id}": {
|
|
||||||
"get": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/metrics/by-user/{id}": {
|
||||||
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -526,16 +521,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/peer/by-id/{id}": {
|
|
||||||
"get": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/peer/by-id/{id}": {
|
||||||
|
"get": {
|
||||||
"description": "Normal users can only access their own records. Admins can access all records.",
|
"description": "Normal users can only access their own records. Admins can access all records.",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -585,14 +580,14 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
"description": "Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
|
"description": "Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -657,14 +652,14 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -716,16 +711,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/peer/by-interface/{id}": {
|
|
||||||
"get": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/peer/by-interface/{id}": {
|
||||||
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -765,16 +760,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/peer/by-user/{id}": {
|
|
||||||
"get": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/peer/by-user/{id}": {
|
||||||
|
"get": {
|
||||||
"description": "Normal users can only access their own records. Admins can access all records.",
|
"description": "Normal users can only access their own records. Admins can access all records.",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -815,16 +810,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/peer/new": {
|
|
||||||
"post": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/peer/new": {
|
||||||
|
"post": {
|
||||||
"description": "Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
|
"description": "Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -882,16 +877,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/peer/prepare/{id}": {
|
|
||||||
"get": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/peer/prepare/{id}": {
|
||||||
|
"get": {
|
||||||
"description": "This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.",
|
"description": "This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -947,16 +942,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/provisioning/data/peer-config": {
|
|
||||||
"get": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/provisioning/data/peer-config": {
|
||||||
|
"get": {
|
||||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||||
"produces": [
|
"produces": [
|
||||||
"text/plain",
|
"text/plain",
|
||||||
@@ -1013,16 +1008,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/provisioning/data/peer-qr": {
|
|
||||||
"get": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/provisioning/data/peer-qr": {
|
||||||
|
"get": {
|
||||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||||
"produces": [
|
"produces": [
|
||||||
"image/png",
|
"image/png",
|
||||||
@@ -1079,16 +1074,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/provisioning/data/user-info": {
|
|
||||||
"get": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/provisioning/data/user-info": {
|
||||||
|
"get": {
|
||||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -1149,16 +1144,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/provisioning/new-peer": {
|
|
||||||
"post": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/provisioning/new-peer": {
|
||||||
|
"post": {
|
||||||
"description": "Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.",
|
"description": "Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -1216,16 +1211,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/user/all": {
|
|
||||||
"get": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/user/all": {
|
||||||
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1256,16 +1251,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/user/by-id/{id}": {
|
|
||||||
"get": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/user/by-id/{id}": {
|
||||||
|
"get": {
|
||||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -1315,14 +1310,14 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
"description": "Only admins can update existing records.",
|
"description": "Only admins can update existing records.",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -1387,14 +1382,14 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1446,16 +1441,16 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"/user/new": {
|
|
||||||
"post": {
|
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"BasicAuth": []
|
"BasicAuth": []
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/user/new": {
|
||||||
|
"post": {
|
||||||
"description": "Only admins can create new records.",
|
"description": "Only admins can create new records.",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -1513,7 +1508,12 @@
|
|||||||
"$ref": "#/definitions/models.Error"
|
"$ref": "#/definitions/models.Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BasicAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2132,6 +2132,22 @@
|
|||||||
"minLength": 32,
|
"minLength": 32,
|
||||||
"example": ""
|
"example": ""
|
||||||
},
|
},
|
||||||
|
"AuthSources": {
|
||||||
|
"description": "The source of the user. This field is optional.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"db",
|
||||||
|
"ldap",
|
||||||
|
"oauth"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"readOnly": true,
|
||||||
|
"example": [
|
||||||
|
"db"
|
||||||
|
]
|
||||||
|
},
|
||||||
"Department": {
|
"Department": {
|
||||||
"description": "The department of the user. This field is optional.",
|
"description": "The department of the user. This field is optional.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -2205,20 +2221,6 @@
|
|||||||
"description": "The phone number of the user. This field is optional.",
|
"description": "The phone number of the user. This field is optional.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "+1234546789"
|
"example": "+1234546789"
|
||||||
},
|
|
||||||
"ProviderName": {
|
|
||||||
"description": "The name of the authentication provider. This field is read-only.",
|
|
||||||
"type": "string",
|
|
||||||
"readOnly": true,
|
|
||||||
"example": ""
|
|
||||||
},
|
|
||||||
"Source": {
|
|
||||||
"description": "The source of the user. This field is optional.",
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"db"
|
|
||||||
],
|
|
||||||
"example": "db"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -490,6 +490,18 @@ definitions:
|
|||||||
maxLength: 64
|
maxLength: 64
|
||||||
minLength: 32
|
minLength: 32
|
||||||
type: string
|
type: string
|
||||||
|
AuthSources:
|
||||||
|
description: The source of the user. This field is optional.
|
||||||
|
example:
|
||||||
|
- db
|
||||||
|
items:
|
||||||
|
enum:
|
||||||
|
- db
|
||||||
|
- ldap
|
||||||
|
- oauth
|
||||||
|
type: string
|
||||||
|
readOnly: true
|
||||||
|
type: array
|
||||||
Department:
|
Department:
|
||||||
description: The department of the user. This field is optional.
|
description: The department of the user. This field is optional.
|
||||||
example: Software Development
|
example: Software Development
|
||||||
@@ -552,17 +564,6 @@ definitions:
|
|||||||
description: The phone number of the user. This field is optional.
|
description: The phone number of the user. This field is optional.
|
||||||
example: "+1234546789"
|
example: "+1234546789"
|
||||||
type: string
|
type: string
|
||||||
ProviderName:
|
|
||||||
description: The name of the authentication provider. This field is read-only.
|
|
||||||
example: ""
|
|
||||||
readOnly: true
|
|
||||||
type: string
|
|
||||||
Source:
|
|
||||||
description: The source of the user. This field is optional.
|
|
||||||
enum:
|
|
||||||
- db
|
|
||||||
example: db
|
|
||||||
type: string
|
|
||||||
required:
|
required:
|
||||||
- Identifier
|
- Identifier
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 709 B |
@@ -6,9 +6,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||||
<title>WireGuard Portal API</title>
|
<title>WireGuard Portal API</title>
|
||||||
<meta name="description" content="WireGuard Portal API">
|
<meta name="description" content="WireGuard Portal API">
|
||||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
<link rel="stylesheet" href="{{$.BasePath}}/css/bootstrap.min.css">
|
||||||
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
|
<link rel="stylesheet" href="{{$.BasePath}}/fonts/fontawesome-all.min.css">
|
||||||
<link rel="shortcut icon" type="image/x-icon" href="/app/favicon.ico">
|
<link rel="shortcut icon" type="image/x-icon" href="{{$.BasePath}}/app/favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body id="page-top" class="d-flex flex-column min-vh-100">
|
<body id="page-top" class="d-flex flex-column min-vh-100">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<div class="card-header">SPA Api</div>
|
<div class="card-header">SPA Api</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="card-text">This endpoint is used by the Single Page Application (the Frontend/UI).</p>
|
<p class="card-text">This endpoint is used by the Single Page Application (the Frontend/UI).</p>
|
||||||
<a href="/api/v0/doc.html" title="API version 0" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
|
<a href="{{$.BasePath}}/api/v0/doc.html" title="API version 0" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<div class="card-header"><b>Version 1</b></div>
|
<div class="card-header"><b>Version 1</b></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="card-text">This is the current main API endpoint.</p>
|
<p class="card-text">This is the current main API endpoint.</p>
|
||||||
<a href="/api/v1/doc.html" title="API version 1" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
|
<a href="{{$.BasePath}}/api/v1/doc.html" title="API version 1" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,17 +43,17 @@
|
|||||||
<div class="card-header">Version 2</div>
|
<div class="card-header">Version 2</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="card-text">This will be a future API version, it is currently work in progress.</p>
|
<p class="card-text">This will be a future API version, it is currently work in progress.</p>
|
||||||
<a href="/api/v2/doc.html" title="API version 2" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
|
<a href="{{$.BasePath}}/api/v2/doc.html" title="API version 2" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{template "prt_footer.gohtml" .}}
|
{{template "prt_footer.gohtml" .}}
|
||||||
<script src="/js/jquery.min.js"></script>
|
<script src="{{$.BasePath}}/js/jquery.min.js"></script>
|
||||||
<script src="/js/jquery.easing.js"></script>
|
<script src="{{$.BasePath}}/js/jquery.easing.js"></script>
|
||||||
<script src="/js/popper.min.js"></script>
|
<script src="{{$.BasePath}}/js/popper.min.js"></script>
|
||||||
<script src="/js/bootstrap.bundle.min.js"></script>
|
<script src="{{$.BasePath}}/js/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<a class="navbar-brand" href="/"><img src="/img/header-logo.png" alt="Prolicht"/></a>
|
<a class="navbar-brand" href="/"><img src="{{$.BasePath}}/img/header-logo.png" alt="Prolicht"/></a>
|
||||||
<div id="topNavbar" class="navbar-collapse collapse">
|
<div id="topNavbar" class="navbar-collapse collapse">
|
||||||
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
|
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
allow-spec-file-load="false"
|
allow-spec-file-load="false"
|
||||||
allow-spec-file-download="true"
|
allow-spec-file-download="true"
|
||||||
>
|
>
|
||||||
<img slot="logo" src="/img/header-logo-small.png" style="width:50px; height:50px"/>
|
<img slot="logo" src="{{$.BasePath}}/img/header-logo-small.png" style="width:50px; height:50px"/>
|
||||||
|
|
||||||
<p slot="footer" style="margin:0; padding:16px 36px; background-color:#f76b39; color:#fff; text-align:center;" >
|
<p slot="footer" style="margin:0; padding:16px 36px; background-color:#f76b39; color:#fff; text-align:center;" >
|
||||||
Copyright © WireGuard Portal {{$.Year}}, version {{$.Version}}
|
Copyright © WireGuard Portal {{$.Year}}, version {{$.Version}}
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-pkgz/routegroup"
|
"github.com/go-pkgz/routegroup"
|
||||||
@@ -35,6 +39,7 @@ type ApiEndpointSetupFunc func() (ApiVersion, GroupSetupFn)
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
server *routegroup.Bundle
|
server *routegroup.Bundle
|
||||||
|
root *routegroup.Bundle // root is the web-root (potentially with path prefix)
|
||||||
tpl *respond.TemplateRenderer
|
tpl *respond.TemplateRenderer
|
||||||
versions map[ApiVersion]*routegroup.Bundle
|
versions map[ApiVersion]*routegroup.Bundle
|
||||||
}
|
}
|
||||||
@@ -75,13 +80,42 @@ func NewServer(cfg *config.Config, endpoints ...ApiEndpointSetupFunc) (*Server,
|
|||||||
template.Must(template.New("").ParseFS(apiTemplates, "assets/tpl/*.gohtml")),
|
template.Must(template.New("").ParseFS(apiTemplates, "assets/tpl/*.gohtml")),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Serve static files
|
// Mount base path if configured
|
||||||
|
s.root = s.server
|
||||||
|
if s.cfg.Web.BasePath != "" {
|
||||||
|
s.root = s.server.Mount(s.cfg.Web.BasePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve static files (under base path if configured)
|
||||||
imgFs := http.FS(fsMust(fs.Sub(apiStatics, "assets/img")))
|
imgFs := http.FS(fsMust(fs.Sub(apiStatics, "assets/img")))
|
||||||
s.server.HandleFiles("/css", http.FS(fsMust(fs.Sub(apiStatics, "assets/css"))))
|
s.root.HandleFiles("/css", http.FS(fsMust(fs.Sub(apiStatics, "assets/css"))))
|
||||||
s.server.HandleFiles("/js", http.FS(fsMust(fs.Sub(apiStatics, "assets/js"))))
|
s.root.HandleFiles("/js", http.FS(fsMust(fs.Sub(apiStatics, "assets/js"))))
|
||||||
s.server.HandleFiles("/img", imgFs)
|
s.root.HandleFiles("/img", imgFs)
|
||||||
s.server.HandleFiles("/fonts", http.FS(fsMust(fs.Sub(apiStatics, "assets/fonts"))))
|
s.root.HandleFiles("/fonts", http.FS(fsMust(fs.Sub(apiStatics, "assets/fonts"))))
|
||||||
s.server.HandleFiles("/doc", http.FS(fsMust(fs.Sub(apiStatics, "assets/doc"))))
|
if cfg.Web.BasePath == "" {
|
||||||
|
s.root.HandleFiles("/doc", http.FS(fsMust(fs.Sub(apiStatics, "assets/doc"))))
|
||||||
|
} else {
|
||||||
|
customV0File, _ := fs.ReadFile(fsMust(fs.Sub(apiStatics, "assets/doc")), "v0_swagger.yaml")
|
||||||
|
customV1File, _ := fs.ReadFile(fsMust(fs.Sub(apiStatics, "assets/doc")), "v1_swagger.yaml")
|
||||||
|
customV0File = []byte(strings.Replace(string(customV0File),
|
||||||
|
"basePath: /api/v0", "basePath: "+cfg.Web.BasePath+"/api/v0", 1))
|
||||||
|
customV1File = []byte(strings.Replace(string(customV1File),
|
||||||
|
"basePath: /api/v1", "basePath: "+cfg.Web.BasePath+"/api/v1", 1))
|
||||||
|
|
||||||
|
s.root.HandleFunc("GET /doc/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == s.cfg.Web.BasePath+"/doc/v0_swagger.yaml" {
|
||||||
|
respond.Data(w, http.StatusOK, "application/yaml", customV0File)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.URL.Path == s.cfg.Web.BasePath+"/doc/v1_swagger.yaml" {
|
||||||
|
respond.Data(w, http.StatusOK, "application/yaml", customV1File)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond.Status(w, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Setup routes
|
// Setup routes
|
||||||
s.setupRoutes(endpoints...)
|
s.setupRoutes(endpoints...)
|
||||||
@@ -126,14 +160,14 @@ func (s *Server) Run(ctx context.Context, listenAddress string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
|
func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
|
||||||
s.server.HandleFunc("GET /api", s.landingPage)
|
s.root.HandleFunc("GET /api", s.landingPage)
|
||||||
s.versions = make(map[ApiVersion]*routegroup.Bundle)
|
s.versions = make(map[ApiVersion]*routegroup.Bundle)
|
||||||
|
|
||||||
for _, setupFunc := range endpoints {
|
for _, setupFunc := range endpoints {
|
||||||
version, groupSetupFn := setupFunc()
|
version, groupSetupFn := setupFunc()
|
||||||
|
|
||||||
if _, ok := s.versions[version]; !ok {
|
if _, ok := s.versions[version]; !ok {
|
||||||
s.versions[version] = s.server.Mount(fmt.Sprintf("/api/%s", version))
|
s.versions[version] = s.root.Mount(fmt.Sprintf("/api/%s", version))
|
||||||
|
|
||||||
// OpenAPI documentation (via RapiDoc)
|
// OpenAPI documentation (via RapiDoc)
|
||||||
s.versions[version].HandleFunc("GET /swagger/index.html", s.rapiDocHandler(version)) // Deprecated: old link
|
s.versions[version].HandleFunc("GET /swagger/index.html", s.rapiDocHandler(version)) // Deprecated: old link
|
||||||
@@ -147,38 +181,193 @@ func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
|
|||||||
|
|
||||||
func (s *Server) setupFrontendRoutes() {
|
func (s *Server) setupFrontendRoutes() {
|
||||||
// Serve static files
|
// Serve static files
|
||||||
s.server.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
s.root.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
respond.Redirect(w, r, http.StatusMovedPermanently, "/app")
|
respond.Redirect(w, r, http.StatusMovedPermanently, s.cfg.Web.BasePath+"/app")
|
||||||
})
|
})
|
||||||
|
|
||||||
s.server.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
s.root.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||||
respond.Redirect(w, r, http.StatusMovedPermanently, "/app/favicon.ico")
|
respond.Redirect(w, r, http.StatusMovedPermanently, s.cfg.Web.BasePath+"/app/favicon.ico")
|
||||||
})
|
})
|
||||||
|
|
||||||
s.server.HandleFiles("/app", http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
|
// If a custom frontend path is configured, serve files from there when it contains content.
|
||||||
|
// If the directory is empty or missing, populate it with the embedded frontend-dist content first.
|
||||||
|
useEmbeddedFrontend := true
|
||||||
|
if s.cfg.Web.FrontendFilePath != "" {
|
||||||
|
if err := os.MkdirAll(s.cfg.Web.FrontendFilePath, 0755); err != nil {
|
||||||
|
slog.Error("failed to create frontend base directory", "path", s.cfg.Web.FrontendFilePath, "error", err)
|
||||||
|
} else {
|
||||||
|
ok := true
|
||||||
|
hasFiles, err := dirHasFiles(s.cfg.Web.FrontendFilePath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to check frontend base directory", "path", s.cfg.Web.FrontendFilePath, "error", err)
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if !hasFiles && ok {
|
||||||
|
embeddedFS := fsMust(fs.Sub(frontendStatics, "frontend-dist"))
|
||||||
|
if err := copyEmbedDirToDisk(embeddedFS, s.cfg.Web.FrontendFilePath); err != nil {
|
||||||
|
slog.Error("failed to populate frontend base directory from embedded assets",
|
||||||
|
"path", s.cfg.Web.FrontendFilePath, "error", err)
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
// serve files from FS
|
||||||
|
slog.Debug("serving frontend files from custom path", "path", s.cfg.Web.FrontendFilePath)
|
||||||
|
useEmbeddedFrontend = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileServer http.Handler
|
||||||
|
if useEmbeddedFrontend {
|
||||||
|
fileServer = http.FileServer(http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
|
||||||
|
} else {
|
||||||
|
fileServer = http.FileServer(http.Dir(s.cfg.Web.FrontendFilePath))
|
||||||
|
}
|
||||||
|
fileServer = http.StripPrefix(s.cfg.Web.BasePath+"/app", fileServer)
|
||||||
|
|
||||||
|
// Modify index.html and CSS to include the correct base path.
|
||||||
|
var customIndexFile, customCssFile []byte
|
||||||
|
var customCssFileName string
|
||||||
|
if s.cfg.Web.BasePath != "" {
|
||||||
|
customIndexFile, customCssFile, customCssFileName = s.updateBasePathInFrontend(useEmbeddedFrontend)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.root.HandleFunc("GET /app/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// serve a custom index.html file with the correct base path applied
|
||||||
|
if s.cfg.Web.BasePath != "" && r.URL.Path == s.cfg.Web.BasePath+"/app/" {
|
||||||
|
respond.Data(w, http.StatusOK, "text/html", customIndexFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve a custom CSS file with the correct base path applied
|
||||||
|
if s.cfg.Web.BasePath != "" && r.URL.Path == s.cfg.Web.BasePath+"/app/assets/"+customCssFileName {
|
||||||
|
respond.Data(w, http.StatusOK, "text/css", customCssFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// pass all other requests to the file server
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) landingPage(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) landingPage(w http.ResponseWriter, _ *http.Request) {
|
||||||
s.tpl.HTML(w, http.StatusOK, "index.gohtml", respond.TplData{
|
s.tpl.HTML(w, http.StatusOK, "index.gohtml", respond.TplData{
|
||||||
"Version": internal.Version,
|
"BasePath": s.cfg.Web.BasePath,
|
||||||
"Year": time.Now().Year(),
|
"Version": internal.Version,
|
||||||
|
"Year": time.Now().Year(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) rapiDocHandler(version ApiVersion) http.HandlerFunc {
|
func (s *Server) rapiDocHandler(version ApiVersion) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
s.tpl.HTML(w, http.StatusOK, "rapidoc.gohtml", respond.TplData{
|
s.tpl.HTML(w, http.StatusOK, "rapidoc.gohtml", respond.TplData{
|
||||||
"RapiDocSource": "/js/rapidoc-min.js",
|
"RapiDocSource": s.cfg.Web.BasePath + "/js/rapidoc-min.js",
|
||||||
"ApiSpecUrl": fmt.Sprintf("/doc/%s_swagger.yaml", version),
|
"BasePath": s.cfg.Web.BasePath,
|
||||||
|
"ApiSpecUrl": fmt.Sprintf("%s/doc/%s_swagger.yaml", s.cfg.Web.BasePath, version),
|
||||||
"Version": internal.Version,
|
"Version": internal.Version,
|
||||||
"Year": time.Now().Year(),
|
"Year": time.Now().Year(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateBasePathInFrontend(useEmbeddedFrontend bool) ([]byte, []byte, string) {
|
||||||
|
if s.cfg.Web.BasePath == "" {
|
||||||
|
return nil, nil, "" // nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
var customIndexFile []byte
|
||||||
|
if useEmbeddedFrontend {
|
||||||
|
customIndexFile, _ = fs.ReadFile(fsMust(fs.Sub(frontendStatics, "frontend-dist")), "index.html")
|
||||||
|
} else {
|
||||||
|
customIndexFile, _ = os.ReadFile(filepath.Join(s.cfg.Web.FrontendFilePath, "index.html"))
|
||||||
|
}
|
||||||
|
newIndexStr := strings.ReplaceAll(string(customIndexFile), "src=\"/", "src=\""+s.cfg.Web.BasePath+"/")
|
||||||
|
newIndexStr = strings.ReplaceAll(newIndexStr, "href=\"/", "href=\""+s.cfg.Web.BasePath+"/")
|
||||||
|
|
||||||
|
re := regexp.MustCompile(`/app/assets/(index-.+.css)`)
|
||||||
|
match := re.FindStringSubmatch(newIndexStr)
|
||||||
|
cssFileName := match[1]
|
||||||
|
|
||||||
|
var customCssFile []byte
|
||||||
|
if useEmbeddedFrontend {
|
||||||
|
customCssFile, _ = fs.ReadFile(fsMust(fs.Sub(frontendStatics, "frontend-dist")), "assets/"+cssFileName)
|
||||||
|
} else {
|
||||||
|
customCssFile, _ = os.ReadFile(filepath.Join(s.cfg.Web.FrontendFilePath, "/assets/", cssFileName))
|
||||||
|
}
|
||||||
|
newCssStr := strings.ReplaceAll(string(customCssFile), "/app/assets/", s.cfg.Web.BasePath+"/app/assets/")
|
||||||
|
|
||||||
|
return []byte(newIndexStr), []byte(newCssStr), cssFileName
|
||||||
|
}
|
||||||
|
|
||||||
func fsMust(f fs.FS, err error) fs.FS {
|
func fsMust(f fs.FS, err error) fs.FS {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dirHasFiles returns true if the directory contains at least one file (non-directory).
|
||||||
|
func dirHasFiles(dir string) (bool, error) {
|
||||||
|
d, err := os.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer d.Close()
|
||||||
|
|
||||||
|
// Read a few entries; if any entry exists, consider it having files/dirs.
|
||||||
|
// We want to know if there is at least one file; if only subdirs exist, still treat as content.
|
||||||
|
entries, err := d.Readdir(-1)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
// check recursively
|
||||||
|
has, err := dirHasFiles(filepath.Join(dir, e.Name()))
|
||||||
|
if err == nil && has {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// regular file
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEmbedDirToDisk copies the contents of srcFS into dstDir on disk.
|
||||||
|
func copyEmbedDirToDisk(srcFS fs.FS, dstDir string) error {
|
||||||
|
return fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := filepath.Join(dstDir, path)
|
||||||
|
if d.IsDir() {
|
||||||
|
return os.MkdirAll(target, 0755)
|
||||||
|
}
|
||||||
|
// ensure parent dir exists
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// open source file
|
||||||
|
f, err := srcFS.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
out, err := os.Create(target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, f); err != nil {
|
||||||
|
_ = out.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -118,3 +119,30 @@ func (p PeerService) SendPeerEmail(
|
|||||||
func (p PeerService) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) {
|
func (p PeerService) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) {
|
||||||
return p.peers.GetPeerStats(ctx, id)
|
return p.peers.GetPeerStats(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p PeerService) BulkDelete(ctx context.Context, ids []domain.PeerIdentifier) error {
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := p.peers.DeletePeer(ctx, id); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete peer %s: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p PeerService) BulkUpdate(ctx context.Context, ids []domain.PeerIdentifier, updateFn func(*domain.Peer)) error {
|
||||||
|
for _, id := range ids {
|
||||||
|
peer, err := p.peers.GetPeer(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get peer %s: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFn(peer)
|
||||||
|
|
||||||
|
if _, err := p.peers.UpdatePeer(ctx, peer); err != nil {
|
||||||
|
return fmt.Errorf("failed to update peer %s: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package backend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/h44z/wg-portal/internal/config"
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
"github.com/h44z/wg-portal/internal/domain"
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
@@ -70,6 +73,50 @@ func (u UserService) DeactivateApi(ctx context.Context, id domain.UserIdentifier
|
|||||||
return u.users.DeactivateApi(ctx, id)
|
return u.users.DeactivateApi(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u UserService) ChangePassword(
|
||||||
|
ctx context.Context,
|
||||||
|
id domain.UserIdentifier,
|
||||||
|
oldPassword, newPassword string,
|
||||||
|
) (*domain.User, error) {
|
||||||
|
oldPassword = strings.TrimSpace(oldPassword)
|
||||||
|
newPassword = strings.TrimSpace(newPassword)
|
||||||
|
|
||||||
|
if newPassword == "" {
|
||||||
|
return nil, fmt.Errorf("new password must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure that the new password is different from the old one
|
||||||
|
if oldPassword == newPassword {
|
||||||
|
return nil, fmt.Errorf("new password must be different from the old one")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := u.users.GetUser(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure that the user uses the database backend; otherwise we can't change the password
|
||||||
|
if !slices.ContainsFunc(user.Authentications, func(authentication domain.UserAuthentication) bool {
|
||||||
|
return authentication.Source == domain.UserSourceDatabase
|
||||||
|
}) {
|
||||||
|
return nil, fmt.Errorf("user has no linked authentication source that does support password changes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate old password
|
||||||
|
if user.CheckPassword(oldPassword) != nil {
|
||||||
|
return nil, fmt.Errorf("current password is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = domain.PrivateString(newPassword)
|
||||||
|
|
||||||
|
// ensure that the new password is strong enough
|
||||||
|
if err := user.HasWeakPassword(u.cfg.Auth.MinPasswordLength); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return u.users.UpdateUser(ctx, user)
|
||||||
|
}
|
||||||
|
|
||||||
func (u UserService) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) {
|
func (u UserService) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) {
|
||||||
return u.wg.GetUserPeers(ctx, id)
|
return u.wg.GetUserPeers(ctx, id)
|
||||||
}
|
}
|
||||||
@@ -81,3 +128,30 @@ func (u UserService) GetUserPeerStats(ctx context.Context, id domain.UserIdentif
|
|||||||
func (u UserService) GetUserInterfaces(ctx context.Context, id domain.UserIdentifier) ([]domain.Interface, error) {
|
func (u UserService) GetUserInterfaces(ctx context.Context, id domain.UserIdentifier) ([]domain.Interface, error) {
|
||||||
return u.wg.GetUserInterfaces(ctx, id)
|
return u.wg.GetUserInterfaces(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u UserService) BulkDelete(ctx context.Context, ids []domain.UserIdentifier) error {
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := u.users.DeleteUser(ctx, id); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete user %s: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u UserService) BulkUpdate(ctx context.Context, ids []domain.UserIdentifier, updateFn func(*domain.User)) error {
|
||||||
|
for _, id := range ids {
|
||||||
|
user, err := u.users.GetUser(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get user %s: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFn(user)
|
||||||
|
|
||||||
|
if _, err := u.users.UpdateUser(ctx, user); err != nil {
|
||||||
|
return fmt.Errorf("failed to update user %s: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
|||||||
// @Router /config/frontend.js [get]
|
// @Router /config/frontend.js [get]
|
||||||
func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
|
func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
backendUrl := fmt.Sprintf("%s/api/v0", e.cfg.Web.ExternalUrl)
|
basePath := e.cfg.Web.BasePath
|
||||||
|
backendUrl := fmt.Sprintf("%s%s/api/v0", e.cfg.Web.ExternalUrl, basePath)
|
||||||
if request.Header(r, "x-wg-dev") != "" {
|
if request.Header(r, "x-wg-dev") != "" {
|
||||||
referer := request.Header(r, "Referer")
|
referer := request.Header(r, "Referer")
|
||||||
host := "localhost"
|
host := "localhost"
|
||||||
@@ -76,12 +77,13 @@ func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
host, port, _ = net.SplitHostPort(parsedReferer.Host)
|
host, port, _ = net.SplitHostPort(parsedReferer.Host)
|
||||||
}
|
}
|
||||||
backendUrl = fmt.Sprintf("http://%s:%s/api/v0", host,
|
backendUrl = fmt.Sprintf("http://%s:%s%s/api/v0", host,
|
||||||
port) // override if request comes from frontend started with npm run dev
|
port, basePath) // override if request comes from frontend started with npm run dev
|
||||||
}
|
}
|
||||||
|
|
||||||
e.tpl.Render(w, http.StatusOK, "frontend_config.js.gotpl", "text/javascript", map[string]any{
|
e.tpl.Render(w, http.StatusOK, "frontend_config.js.gotpl", "text/javascript", map[string]any{
|
||||||
"BackendUrl": backendUrl,
|
"BackendUrl": backendUrl,
|
||||||
|
"BasePath": basePath,
|
||||||
"Version": internal.Version,
|
"Version": internal.Version,
|
||||||
"SiteTitle": e.cfg.Web.SiteTitle,
|
"SiteTitle": e.cfg.Web.SiteTitle,
|
||||||
"SiteCompanyName": e.cfg.Web.SiteCompanyName,
|
"SiteCompanyName": e.cfg.Web.SiteCompanyName,
|
||||||
@@ -143,6 +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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-pkgz/routegroup"
|
"github.com/go-pkgz/routegroup"
|
||||||
|
|
||||||
@@ -41,6 +42,10 @@ type PeerService interface {
|
|||||||
SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error
|
SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error
|
||||||
// GetPeerStats returns the peer stats for the given interface.
|
// GetPeerStats returns the peer stats for the given interface.
|
||||||
GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error)
|
GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error)
|
||||||
|
// BulkDelete deletes multiple peers.
|
||||||
|
BulkDelete(context.Context, []domain.PeerIdentifier) error
|
||||||
|
// BulkUpdate modifies multiple peers.
|
||||||
|
BulkUpdate(context.Context, []domain.PeerIdentifier, func(*domain.Peer)) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeerEndpoint struct {
|
type PeerEndpoint struct {
|
||||||
@@ -84,6 +89,9 @@ func (e PeerEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
|||||||
apiGroup.HandleFunc("GET /{id}", e.handleSingleGet())
|
apiGroup.HandleFunc("GET /{id}", e.handleSingleGet())
|
||||||
apiGroup.HandleFunc("PUT /{id}", e.handleUpdatePut())
|
apiGroup.HandleFunc("PUT /{id}", e.handleUpdatePut())
|
||||||
apiGroup.HandleFunc("DELETE /{id}", e.handleDelete())
|
apiGroup.HandleFunc("DELETE /{id}", e.handleDelete())
|
||||||
|
apiGroup.HandleFunc("POST /bulk-delete", e.handleBulkDelete())
|
||||||
|
apiGroup.HandleFunc("POST /bulk-enable", e.handleBulkEnable())
|
||||||
|
apiGroup.HandleFunc("POST /bulk-disable", e.handleBulkDisable())
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAllGet returns a gorm Handler function.
|
// handleAllGet returns a gorm Handler function.
|
||||||
@@ -521,3 +529,114 @@ func (e PeerEndpoint) getConfigStyle(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
return configStyle
|
return configStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleBulkDelete returns a gorm Handler function.
|
||||||
|
//
|
||||||
|
// @ID peers_handleBulkDelete
|
||||||
|
// @Tags Peer
|
||||||
|
// @Summary Bulk delete selected peers.
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body model.BulkPeerRequest true "A list of peer identifiers to delete"
|
||||||
|
// @Success 204 "No content if deletion was successful"
|
||||||
|
// @Failure 400 {object} model.Error
|
||||||
|
// @Failure 500 {object} model.Error
|
||||||
|
// @Router /peer/bulk-delete [post]
|
||||||
|
func (e PeerEndpoint) handleBulkDelete() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.BulkPeerRequest
|
||||||
|
if err := request.BodyJson(r, &req); err != nil {
|
||||||
|
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]domain.PeerIdentifier, len(req.Identifiers))
|
||||||
|
for i, id := range req.Identifiers {
|
||||||
|
ids[i] = domain.PeerIdentifier(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := e.peerService.BulkDelete(r.Context(), ids)
|
||||||
|
if err != nil {
|
||||||
|
respond.JSON(w, http.StatusInternalServerError,
|
||||||
|
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBulkEnable returns a gorm Handler function.
|
||||||
|
//
|
||||||
|
// @ID peers_handleBulkEnable
|
||||||
|
// @Tags Peer
|
||||||
|
// @Summary Bulk enable selected peers.
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body model.BulkPeerRequest true "A list of peer identifiers to enable"
|
||||||
|
// @Success 204 "No content if action was successful"
|
||||||
|
// @Failure 400 {object} model.Error
|
||||||
|
// @Failure 500 {object} model.Error
|
||||||
|
// @Router /peer/bulk-enable [post]
|
||||||
|
func (e PeerEndpoint) handleBulkEnable() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.BulkPeerRequest
|
||||||
|
if err := request.BodyJson(r, &req); err != nil {
|
||||||
|
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]domain.PeerIdentifier, len(req.Identifiers))
|
||||||
|
for i, id := range req.Identifiers {
|
||||||
|
ids[i] = domain.PeerIdentifier(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := e.peerService.BulkUpdate(r.Context(), ids, func(p *domain.Peer) {
|
||||||
|
p.Disabled = nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.JSON(w, http.StatusInternalServerError,
|
||||||
|
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBulkDisable returns a gorm Handler function.
|
||||||
|
//
|
||||||
|
// @ID peers_handleBulkDisable
|
||||||
|
// @Tags Peer
|
||||||
|
// @Summary Bulk disable selected peers.
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body model.BulkPeerRequest true "A list of peer identifiers to disable"
|
||||||
|
// @Success 204 "No content if action was successful"
|
||||||
|
// @Failure 400 {object} model.Error
|
||||||
|
// @Failure 500 {object} model.Error
|
||||||
|
// @Router /peer/bulk-disable [post]
|
||||||
|
func (e PeerEndpoint) handleBulkDisable() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.BulkPeerRequest
|
||||||
|
if err := request.BodyJson(r, &req); err != nil {
|
||||||
|
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]domain.PeerIdentifier, len(req.Identifiers))
|
||||||
|
for i, id := range req.Identifiers {
|
||||||
|
ids[i] = domain.PeerIdentifier(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
err := e.peerService.BulkUpdate(r.Context(), ids, func(p *domain.Peer) {
|
||||||
|
p.Disabled = &now
|
||||||
|
p.DisabledReason = domain.DisabledReasonAdmin
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.JSON(w, http.StatusInternalServerError,
|
||||||
|
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-pkgz/routegroup"
|
"github.com/go-pkgz/routegroup"
|
||||||
|
|
||||||
@@ -28,12 +29,18 @@ type UserService interface {
|
|||||||
ActivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
ActivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
||||||
// DeactivateApi disables the API for the user with the given id.
|
// DeactivateApi disables the API for the user with the given id.
|
||||||
DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
||||||
|
// ChangePassword changes the password for the user with the given id.
|
||||||
|
ChangePassword(ctx context.Context, id domain.UserIdentifier, oldPassword, newPassword string) (*domain.User, error)
|
||||||
// GetUserPeers returns all peers for the given user.
|
// GetUserPeers returns all peers for the given user.
|
||||||
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
|
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
|
||||||
// GetUserPeerStats returns all peer stats for the given user.
|
// GetUserPeerStats returns all peer stats for the given user.
|
||||||
GetUserPeerStats(ctx context.Context, id domain.UserIdentifier) ([]domain.PeerStatus, error)
|
GetUserPeerStats(ctx context.Context, id domain.UserIdentifier) ([]domain.PeerStatus, error)
|
||||||
// GetUserInterfaces returns all interfaces for the given user.
|
// GetUserInterfaces returns all interfaces for the given user.
|
||||||
GetUserInterfaces(ctx context.Context, id domain.UserIdentifier) ([]domain.Interface, error)
|
GetUserInterfaces(ctx context.Context, id domain.UserIdentifier) ([]domain.Interface, error)
|
||||||
|
// BulkDelete deletes multiple users.
|
||||||
|
BulkDelete(ctx context.Context, ids []domain.UserIdentifier) error
|
||||||
|
// BulkUpdate modifies multiple users.
|
||||||
|
BulkUpdate(ctx context.Context, ids []domain.UserIdentifier, updateFn func(*domain.User)) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserEndpoint struct {
|
type UserEndpoint struct {
|
||||||
@@ -75,6 +82,13 @@ func (e UserEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
|||||||
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet())
|
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet())
|
||||||
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost())
|
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost())
|
||||||
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost())
|
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost())
|
||||||
|
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/change-password",
|
||||||
|
e.handleChangePasswordPost())
|
||||||
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-delete", e.handleBulkDelete())
|
||||||
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-enable", e.handleBulkEnable())
|
||||||
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-disable", e.handleBulkDisable())
|
||||||
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-lock", e.handleBulkLock())
|
||||||
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-unlock", e.handleBulkUnlock())
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAllGet returns a gorm Handler function.
|
// handleAllGet returns a gorm Handler function.
|
||||||
@@ -391,3 +405,255 @@ func (e UserEndpoint) handleApiDisablePost() http.HandlerFunc {
|
|||||||
respond.JSON(w, http.StatusOK, model.NewUser(user, false))
|
respond.JSON(w, http.StatusOK, model.NewUser(user, false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleChangePasswordPost returns a gorm Handler function.
|
||||||
|
//
|
||||||
|
// @ID users_handleChangePasswordPost
|
||||||
|
// @Tags Users
|
||||||
|
// @Summary Change the password for the given user.
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} model.User
|
||||||
|
// @Failure 400 {object} model.Error
|
||||||
|
// @Failure 500 {object} model.Error
|
||||||
|
// @Router /user/{id}/change-password [post]
|
||||||
|
func (e UserEndpoint) handleChangePasswordPost() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userId := Base64UrlDecode(request.Path(r, "id"))
|
||||||
|
if userId == "" {
|
||||||
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
|
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordData struct {
|
||||||
|
OldPassword string `json:"OldPassword"`
|
||||||
|
Password string `json:"Password"`
|
||||||
|
PasswordRepeat string `json:"PasswordRepeat"`
|
||||||
|
}
|
||||||
|
if err := request.BodyJson(r, &passwordData); err != nil {
|
||||||
|
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if passwordData.OldPassword == "" {
|
||||||
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
|
model.Error{Code: http.StatusBadRequest, Message: "old password missing"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if passwordData.Password == "" {
|
||||||
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
|
model.Error{Code: http.StatusBadRequest, Message: "new password missing"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if passwordData.OldPassword == passwordData.Password {
|
||||||
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
|
model.Error{Code: http.StatusBadRequest, Message: "password did not change"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if passwordData.Password != passwordData.PasswordRepeat {
|
||||||
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
|
model.Error{Code: http.StatusBadRequest, Message: "password mismatch"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := e.userService.ChangePassword(r.Context(), domain.UserIdentifier(userId),
|
||||||
|
passwordData.OldPassword, passwordData.Password)
|
||||||
|
if err != nil {
|
||||||
|
respond.JSON(w, http.StatusInternalServerError,
|
||||||
|
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond.JSON(w, http.StatusOK, model.NewUser(user, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBulkDelete returns a gorm Handler function.
|
||||||
|
//
|
||||||
|
// @ID users_handleBulkDelete
|
||||||
|
// @Tags Users
|
||||||
|
// @Summary Bulk delete selected users.
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body model.BulkPeerRequest true "A list of user identifiers to delete"
|
||||||
|
// @Success 204 "No content if deletion was successful"
|
||||||
|
// @Failure 400 {object} model.Error
|
||||||
|
// @Failure 500 {object} model.Error
|
||||||
|
// @Router /user/bulk-delete [post]
|
||||||
|
func (e UserEndpoint) handleBulkDelete() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.BulkUserRequest
|
||||||
|
if err := request.BodyJson(r, &req); err != nil {
|
||||||
|
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]domain.UserIdentifier, len(req.Identifiers))
|
||||||
|
for i, id := range req.Identifiers {
|
||||||
|
ids[i] = domain.UserIdentifier(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := e.userService.BulkDelete(r.Context(), ids)
|
||||||
|
if err != nil {
|
||||||
|
respond.JSON(w, http.StatusInternalServerError,
|
||||||
|
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBulkEnable returns a gorm Handler function.
|
||||||
|
//
|
||||||
|
// @ID users_handleBulkEnable
|
||||||
|
// @Tags Users
|
||||||
|
// @Summary Bulk enable selected users.
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body model.BulkPeerRequest true "A list of user identifiers to enable"
|
||||||
|
// @Success 204 "No content if action was successful"
|
||||||
|
// @Failure 400 {object} model.Error
|
||||||
|
// @Failure 500 {object} model.Error
|
||||||
|
// @Router /user/bulk-enable [post]
|
||||||
|
func (e UserEndpoint) handleBulkEnable() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.BulkUserRequest
|
||||||
|
if err := request.BodyJson(r, &req); err != nil {
|
||||||
|
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]domain.UserIdentifier, len(req.Identifiers))
|
||||||
|
for i, id := range req.Identifiers {
|
||||||
|
ids[i] = domain.UserIdentifier(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
|
||||||
|
user.Disabled = nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.JSON(w, http.StatusInternalServerError,
|
||||||
|
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBulkDisable returns a gorm Handler function.
|
||||||
|
//
|
||||||
|
// @ID users_handleBulkDisable
|
||||||
|
// @Tags Users
|
||||||
|
// @Summary Bulk disable selected users.
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body model.BulkPeerRequest true "A list of user identifiers to disable"
|
||||||
|
// @Success 204 "No content if action was successful"
|
||||||
|
// @Failure 400 {object} model.Error
|
||||||
|
// @Failure 500 {object} model.Error
|
||||||
|
// @Router /user/bulk-disable [post]
|
||||||
|
func (e UserEndpoint) handleBulkDisable() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.BulkUserRequest
|
||||||
|
if err := request.BodyJson(r, &req); err != nil {
|
||||||
|
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]domain.UserIdentifier, len(req.Identifiers))
|
||||||
|
for i, id := range req.Identifiers {
|
||||||
|
ids[i] = domain.UserIdentifier(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
|
||||||
|
user.Disabled = &now
|
||||||
|
user.DisabledReason = domain.DisabledReasonAdmin
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.JSON(w, http.StatusInternalServerError,
|
||||||
|
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBulkLock returns a gorm Handler function.
|
||||||
|
//
|
||||||
|
// @ID users_handleBulkLock
|
||||||
|
// @Tags Users
|
||||||
|
// @Summary Bulk lock selected users.
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body model.BulkPeerRequest true "A list of user identifiers to lock"
|
||||||
|
// @Success 204 "No content if action was successful"
|
||||||
|
// @Failure 400 {object} model.Error
|
||||||
|
// @Failure 500 {object} model.Error
|
||||||
|
// @Router /user/bulk-lock [post]
|
||||||
|
func (e UserEndpoint) handleBulkLock() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.BulkUserRequest
|
||||||
|
if err := request.BodyJson(r, &req); err != nil {
|
||||||
|
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]domain.UserIdentifier, len(req.Identifiers))
|
||||||
|
for i, id := range req.Identifiers {
|
||||||
|
ids[i] = domain.UserIdentifier(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
|
||||||
|
user.Locked = &now
|
||||||
|
user.LockedReason = domain.LockedReasonAdmin
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.JSON(w, http.StatusInternalServerError,
|
||||||
|
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBulkUnlock returns a gorm Handler function.
|
||||||
|
//
|
||||||
|
// @ID users_handleBulkUnlock
|
||||||
|
// @Tags Users
|
||||||
|
// @Summary Bulk unlock selected users.
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body model.BulkPeerRequest true "A list of user identifiers to unlock"
|
||||||
|
// @Success 204 "No content if action was successful"
|
||||||
|
// @Failure 400 {object} model.Error
|
||||||
|
// @Failure 500 {object} model.Error
|
||||||
|
// @Router /user/bulk-unlock [post]
|
||||||
|
func (e UserEndpoint) handleBulkUnlock() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.BulkUserRequest
|
||||||
|
if err := request.BodyJson(r, &req); err != nil {
|
||||||
|
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]domain.UserIdentifier, len(req.Identifiers))
|
||||||
|
for i, id := range req.Identifiers {
|
||||||
|
ids[i] = domain.UserIdentifier(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
|
||||||
|
user.Locked = nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.JSON(w, http.StatusInternalServerError,
|
||||||
|
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
WGPORTAL_BACKEND_BASE_URL="{{ $.BackendUrl }}";
|
WGPORTAL_BACKEND_BASE_URL="{{ $.BackendUrl }}";
|
||||||
|
WGPORTAL_BASE_PATH="{{ $.BasePath }}";
|
||||||
WGPORTAL_VERSION="{{ $.Version }}";
|
WGPORTAL_VERSION="{{ $.Version }}";
|
||||||
WGPORTAL_SITE_TITLE="{{ $.SiteTitle }}";
|
WGPORTAL_SITE_TITLE="{{ $.SiteTitle }}";
|
||||||
WGPORTAL_SITE_COMPANY_NAME="{{ $.SiteCompanyName }}";
|
WGPORTAL_SITE_COMPANY_NAME="{{ $.SiteCompanyName }}";
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ func NewSessionWrapper(cfg *config.Config) *SessionWrapper {
|
|||||||
sessionManager.Cookie.Secure = strings.HasPrefix(cfg.Web.ExternalUrl, "https")
|
sessionManager.Cookie.Secure = strings.HasPrefix(cfg.Web.ExternalUrl, "https")
|
||||||
sessionManager.Cookie.HttpOnly = true
|
sessionManager.Cookie.HttpOnly = true
|
||||||
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
|
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
|
||||||
sessionManager.Cookie.Path = "/"
|
if cfg.Web.BasePath != "" {
|
||||||
|
sessionManager.Cookie.Path = cfg.Web.BasePath
|
||||||
|
} else {
|
||||||
|
sessionManager.Cookie.Path = "/"
|
||||||
|
}
|
||||||
sessionManager.Cookie.Persist = false
|
sessionManager.Cookie.Persist = false
|
||||||
|
|
||||||
wrappedSessionManager := &SessionWrapper{sessionManager}
|
wrappedSessionManager := &SessionWrapper{sessionManager}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Settings struct {
|
|||||||
MinPasswordLength int `json:"MinPasswordLength"`
|
MinPasswordLength int `json:"MinPasswordLength"`
|
||||||
AvailableBackends []SettingsBackendNames `json:"AvailableBackends"`
|
AvailableBackends []SettingsBackendNames `json:"AvailableBackends"`
|
||||||
LoginFormVisible bool `json:"LoginFormVisible"`
|
LoginFormVisible bool `json:"LoginFormVisible"`
|
||||||
|
CreateDefaultPeer bool `json:"CreateDefaultPeer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsBackendNames struct {
|
type SettingsBackendNames struct {
|
||||||
|
|||||||
10
internal/app/api/v0/model/models_bulk.go
Normal file
10
internal/app/api/v0/model/models_bulk.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type BulkPeerRequest struct {
|
||||||
|
Identifiers []string `json:"Identifiers" binding:"required"`
|
||||||
|
Reason string `json:"Reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkUserRequest struct {
|
||||||
|
Identifiers []string `json:"Identifiers" binding:"required"`
|
||||||
|
}
|
||||||
@@ -9,15 +9,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Interface struct {
|
type Interface struct {
|
||||||
Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0
|
Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0
|
||||||
DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface
|
DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface
|
||||||
Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any'
|
Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any'
|
||||||
Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ...
|
Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ...
|
||||||
PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface
|
PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface
|
||||||
PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
|
PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
|
||||||
Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
|
Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
|
||||||
DisabledReason string `json:"DisabledReason"` // the reason why the interface has been disabled
|
DisabledReason string `json:"DisabledReason"` // the reason why the interface has been disabled
|
||||||
SaveConfig bool `json:"SaveConfig"` // automatically persist config changes to the wgX.conf file
|
SaveConfig bool `json:"SaveConfig"` // automatically persist config changes to the wgX.conf file
|
||||||
|
CreateDefaultPeer bool `json:"CreateDefaultPeer"` // if true, default peers will be created for this interface
|
||||||
|
|
||||||
ListenPort int `json:"ListenPort"` // the listening port, for example: 51820
|
ListenPort int `json:"ListenPort"` // the listening port, for example: 51820
|
||||||
Addresses []string `json:"Addresses"` // the interface ip addresses
|
Addresses []string `json:"Addresses"` // the interface ip addresses
|
||||||
@@ -65,6 +66,7 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
|
|||||||
Disabled: src.IsDisabled(),
|
Disabled: src.IsDisabled(),
|
||||||
DisabledReason: src.DisabledReason,
|
DisabledReason: src.DisabledReason,
|
||||||
SaveConfig: src.SaveConfig,
|
SaveConfig: src.SaveConfig,
|
||||||
|
CreateDefaultPeer: src.CreateDefaultPeer,
|
||||||
ListenPort: src.ListenPort,
|
ListenPort: src.ListenPort,
|
||||||
Addresses: domain.CidrsToStringSlice(src.Addresses),
|
Addresses: domain.CidrsToStringSlice(src.Addresses),
|
||||||
Dns: internal.SliceString(src.DnsStr),
|
Dns: internal.SliceString(src.DnsStr),
|
||||||
@@ -151,6 +153,7 @@ func NewDomainInterface(src *Interface) *domain.Interface {
|
|||||||
PreDown: src.PreDown,
|
PreDown: src.PreDown,
|
||||||
PostDown: src.PostDown,
|
PostDown: src.PostDown,
|
||||||
SaveConfig: src.SaveConfig,
|
SaveConfig: src.SaveConfig,
|
||||||
|
CreateDefaultPeer: src.CreateDefaultPeer,
|
||||||
DisplayName: src.DisplayName,
|
DisplayName: src.DisplayName,
|
||||||
Type: domain.InterfaceType(src.Mode),
|
Type: domain.InterfaceType(src.Mode),
|
||||||
Backend: domain.InterfaceBackend(src.Backend),
|
Backend: domain.InterfaceBackend(src.Backend),
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ package model
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/h44z/wg-portal/internal"
|
||||||
"github.com/h44z/wg-portal/internal/domain"
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
Identifier string `json:"Identifier"`
|
Identifier string `json:"Identifier"`
|
||||||
Email string `json:"Email"`
|
Email string `json:"Email"`
|
||||||
Source string `json:"Source"`
|
AuthSources []string `json:"AuthSources"`
|
||||||
ProviderName string `json:"ProviderName"`
|
IsAdmin bool `json:"IsAdmin"`
|
||||||
IsAdmin bool `json:"IsAdmin"`
|
|
||||||
|
|
||||||
Firstname string `json:"Firstname"`
|
Firstname string `json:"Firstname"`
|
||||||
Lastname string `json:"Lastname"`
|
Lastname string `json:"Lastname"`
|
||||||
@@ -29,6 +29,8 @@ type User struct {
|
|||||||
ApiTokenCreated *time.Time `json:"ApiTokenCreated,omitempty"`
|
ApiTokenCreated *time.Time `json:"ApiTokenCreated,omitempty"`
|
||||||
ApiEnabled bool `json:"ApiEnabled"`
|
ApiEnabled bool `json:"ApiEnabled"`
|
||||||
|
|
||||||
|
PersistLocalChanges bool `json:"PersistLocalChanges"`
|
||||||
|
|
||||||
// Calculated
|
// Calculated
|
||||||
|
|
||||||
PeerCount int `json:"PeerCount"`
|
PeerCount int `json:"PeerCount"`
|
||||||
@@ -36,24 +38,26 @@ type User struct {
|
|||||||
|
|
||||||
func NewUser(src *domain.User, exposeCreds bool) *User {
|
func NewUser(src *domain.User, exposeCreds bool) *User {
|
||||||
u := &User{
|
u := &User{
|
||||||
Identifier: string(src.Identifier),
|
Identifier: string(src.Identifier),
|
||||||
Email: src.Email,
|
Email: src.Email,
|
||||||
Source: string(src.Source),
|
AuthSources: internal.Map(src.Authentications, func(authentication domain.UserAuthentication) string {
|
||||||
ProviderName: src.ProviderName,
|
return string(authentication.Source)
|
||||||
IsAdmin: src.IsAdmin,
|
}),
|
||||||
Firstname: src.Firstname,
|
IsAdmin: src.IsAdmin,
|
||||||
Lastname: src.Lastname,
|
Firstname: src.Firstname,
|
||||||
Phone: src.Phone,
|
Lastname: src.Lastname,
|
||||||
Department: src.Department,
|
Phone: src.Phone,
|
||||||
Notes: src.Notes,
|
Department: src.Department,
|
||||||
Password: "", // never fill password
|
Notes: src.Notes,
|
||||||
Disabled: src.IsDisabled(),
|
Password: "", // never fill password
|
||||||
DisabledReason: src.DisabledReason,
|
Disabled: src.IsDisabled(),
|
||||||
Locked: src.IsLocked(),
|
DisabledReason: src.DisabledReason,
|
||||||
LockedReason: src.LockedReason,
|
Locked: src.IsLocked(),
|
||||||
ApiToken: "", // by default, do not expose API token
|
LockedReason: src.LockedReason,
|
||||||
ApiTokenCreated: src.ApiTokenCreated,
|
ApiToken: "", // by default, do not expose API token
|
||||||
ApiEnabled: src.IsApiEnabled(),
|
ApiTokenCreated: src.ApiTokenCreated,
|
||||||
|
ApiEnabled: src.IsApiEnabled(),
|
||||||
|
PersistLocalChanges: src.PersistLocalChanges,
|
||||||
|
|
||||||
PeerCount: src.LinkedPeerCount,
|
PeerCount: src.LinkedPeerCount,
|
||||||
}
|
}
|
||||||
@@ -77,22 +81,21 @@ func NewUsers(src []domain.User) []User {
|
|||||||
func NewDomainUser(src *User) *domain.User {
|
func NewDomainUser(src *User) *domain.User {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
res := &domain.User{
|
res := &domain.User{
|
||||||
Identifier: domain.UserIdentifier(src.Identifier),
|
Identifier: domain.UserIdentifier(src.Identifier),
|
||||||
Email: src.Email,
|
Email: src.Email,
|
||||||
Source: domain.UserSource(src.Source),
|
IsAdmin: src.IsAdmin,
|
||||||
ProviderName: src.ProviderName,
|
Firstname: src.Firstname,
|
||||||
IsAdmin: src.IsAdmin,
|
Lastname: src.Lastname,
|
||||||
Firstname: src.Firstname,
|
Phone: src.Phone,
|
||||||
Lastname: src.Lastname,
|
Department: src.Department,
|
||||||
Phone: src.Phone,
|
Notes: src.Notes,
|
||||||
Department: src.Department,
|
Password: domain.PrivateString(src.Password),
|
||||||
Notes: src.Notes,
|
Disabled: nil, // set below
|
||||||
Password: domain.PrivateString(src.Password),
|
DisabledReason: src.DisabledReason,
|
||||||
Disabled: nil, // set below
|
Locked: nil, // set below
|
||||||
DisabledReason: src.DisabledReason,
|
LockedReason: src.LockedReason,
|
||||||
Locked: nil, // set below
|
LinkedPeerCount: src.PeerCount,
|
||||||
LockedReason: src.LockedReason,
|
PersistLocalChanges: src.PersistLocalChanges,
|
||||||
LinkedPeerCount: src.PeerCount,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if src.Disabled {
|
if src.Disabled {
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ func (e InterfaceEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
|||||||
apiGroup.Use(e.authenticator.LoggedIn(ScopeAdmin))
|
apiGroup.Use(e.authenticator.LoggedIn(ScopeAdmin))
|
||||||
|
|
||||||
apiGroup.HandleFunc("GET /all", e.handleAllGet())
|
apiGroup.HandleFunc("GET /all", e.handleAllGet())
|
||||||
apiGroup.HandleFunc("GET /by-id/{id}", e.handleByIdGet())
|
apiGroup.HandleFunc("GET /by-id/{id...}", e.handleByIdGet())
|
||||||
|
|
||||||
apiGroup.HandleFunc("GET /prepare", e.handlePrepareGet())
|
apiGroup.HandleFunc("GET /prepare", e.handlePrepareGet())
|
||||||
apiGroup.HandleFunc("POST /new", e.handleCreatePost())
|
apiGroup.HandleFunc("POST /new", e.handleCreatePost())
|
||||||
apiGroup.HandleFunc("PUT /by-id/{id}", e.handleUpdatePut())
|
apiGroup.HandleFunc("PUT /by-id/{id...}", e.handleUpdatePut())
|
||||||
apiGroup.HandleFunc("DELETE /by-id/{id}", e.handleDelete())
|
apiGroup.HandleFunc("DELETE /by-id/{id...}", e.handleDelete())
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAllGet returns a gorm Handler function.
|
// handleAllGet returns a gorm Handler function.
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ func (e MetricsEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
|||||||
apiGroup := g.Mount("/metrics")
|
apiGroup := g.Mount("/metrics")
|
||||||
apiGroup.Use(e.authenticator.LoggedIn())
|
apiGroup.Use(e.authenticator.LoggedIn())
|
||||||
|
|
||||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /by-interface/{id}",
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /by-interface/{id...}",
|
||||||
e.handleMetricsForInterfaceGet())
|
e.handleMetricsForInterfaceGet())
|
||||||
apiGroup.HandleFunc("GET /by-user/{id}", e.handleMetricsForUserGet())
|
apiGroup.HandleFunc("GET /by-user/{id...}", e.handleMetricsForUserGet())
|
||||||
apiGroup.HandleFunc("GET /by-peer/{id}", e.handleMetricsForPeerGet())
|
apiGroup.HandleFunc("GET /by-peer/{id...}", e.handleMetricsForPeerGet())
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMetricsForInterfaceGet returns a gorm Handler function.
|
// handleMetricsForInterfaceGet returns a gorm Handler function.
|
||||||
|
|||||||
@@ -47,15 +47,15 @@ func (e PeerEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
|||||||
apiGroup := g.Mount("/peer")
|
apiGroup := g.Mount("/peer")
|
||||||
apiGroup.Use(e.authenticator.LoggedIn())
|
apiGroup.Use(e.authenticator.LoggedIn())
|
||||||
|
|
||||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /by-interface/{id}",
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /by-interface/{id...}",
|
||||||
e.handleAllForInterfaceGet())
|
e.handleAllForInterfaceGet())
|
||||||
apiGroup.HandleFunc("GET /by-user/{id}", e.handleAllForUserGet())
|
apiGroup.HandleFunc("GET /by-user/{id...}", e.handleAllForUserGet())
|
||||||
apiGroup.HandleFunc("GET /by-id/{id}", e.handleByIdGet())
|
apiGroup.HandleFunc("GET /by-id/{id...}", e.handleByIdGet())
|
||||||
|
|
||||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /prepare/{id}", e.handlePrepareGet())
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /prepare/{id...}", e.handlePrepareGet())
|
||||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /new", e.handleCreatePost())
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /new", e.handleCreatePost())
|
||||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("PUT /by-id/{id}", e.handleUpdatePut())
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("PUT /by-id/{id...}", e.handleUpdatePut())
|
||||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("DELETE /by-id/{id}", e.handleDelete())
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("DELETE /by-id/{id...}", e.handleDelete())
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAllForInterfaceGet returns a gorm Handler function.
|
// handleAllForInterfaceGet returns a gorm Handler function.
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ func (e UserEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
|||||||
apiGroup.Use(e.authenticator.LoggedIn())
|
apiGroup.Use(e.authenticator.LoggedIn())
|
||||||
|
|
||||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /all", e.handleAllGet())
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /all", e.handleAllGet())
|
||||||
apiGroup.HandleFunc("GET /by-id/{id}", e.handleByIdGet())
|
apiGroup.HandleFunc("GET /by-id/{id...}", e.handleByIdGet())
|
||||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /new", e.handleCreatePost())
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /new", e.handleCreatePost())
|
||||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("PUT /by-id/{id}", e.handleUpdatePut())
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("PUT /by-id/{id...}", e.handleUpdatePut())
|
||||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("DELETE /by-id/{id}", e.handleDelete())
|
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("DELETE /by-id/{id...}", e.handleDelete())
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAllGet returns a gorm Handler function.
|
// handleAllGet returns a gorm Handler function.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/h44z/wg-portal/internal"
|
||||||
"github.com/h44z/wg-portal/internal/domain"
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,9 +14,7 @@ type User struct {
|
|||||||
// The email address of the user. This field is optional.
|
// The email address of the user. This field is optional.
|
||||||
Email string `json:"Email" binding:"omitempty,email" example:"test@test.com"`
|
Email string `json:"Email" binding:"omitempty,email" example:"test@test.com"`
|
||||||
// The source of the user. This field is optional.
|
// The source of the user. This field is optional.
|
||||||
Source string `json:"Source" binding:"oneof=db" example:"db"`
|
AuthSources []string `json:"AuthSources" readonly:"true" binding:"oneof=db ldap oauth" example:"db"`
|
||||||
// The name of the authentication provider. This field is read-only.
|
|
||||||
ProviderName string `json:"ProviderName,omitempty" readonly:"true" example:""`
|
|
||||||
// If this field is set, the user is an admin.
|
// If this field is set, the user is an admin.
|
||||||
IsAdmin bool `json:"IsAdmin" example:"false"`
|
IsAdmin bool `json:"IsAdmin" example:"false"`
|
||||||
|
|
||||||
@@ -52,10 +51,11 @@ type User struct {
|
|||||||
|
|
||||||
func NewUser(src *domain.User, exposeCredentials bool) *User {
|
func NewUser(src *domain.User, exposeCredentials bool) *User {
|
||||||
u := &User{
|
u := &User{
|
||||||
Identifier: string(src.Identifier),
|
Identifier: string(src.Identifier),
|
||||||
Email: src.Email,
|
Email: src.Email,
|
||||||
Source: string(src.Source),
|
AuthSources: internal.Map(src.Authentications, func(authentication domain.UserAuthentication) string {
|
||||||
ProviderName: src.ProviderName,
|
return string(authentication.Source)
|
||||||
|
}),
|
||||||
IsAdmin: src.IsAdmin,
|
IsAdmin: src.IsAdmin,
|
||||||
Firstname: src.Firstname,
|
Firstname: src.Firstname,
|
||||||
Lastname: src.Lastname,
|
Lastname: src.Lastname,
|
||||||
@@ -93,8 +93,6 @@ func NewDomainUser(src *User) *domain.User {
|
|||||||
res := &domain.User{
|
res := &domain.User{
|
||||||
Identifier: domain.UserIdentifier(src.Identifier),
|
Identifier: domain.UserIdentifier(src.Identifier),
|
||||||
Email: src.Email,
|
Email: src.Email,
|
||||||
Source: domain.UserSource(src.Source),
|
|
||||||
ProviderName: src.ProviderName,
|
|
||||||
IsAdmin: src.IsAdmin,
|
IsAdmin: src.IsAdmin,
|
||||||
Firstname: src.Firstname,
|
Firstname: src.Firstname,
|
||||||
Lastname: src.Lastname,
|
Lastname: src.Lastname,
|
||||||
|
|||||||
@@ -129,8 +129,6 @@ func (a *App) createDefaultUser(ctx context.Context) error {
|
|||||||
},
|
},
|
||||||
Identifier: adminUserId,
|
Identifier: adminUserId,
|
||||||
Email: "admin@wgportal.local",
|
Email: "admin@wgportal.local",
|
||||||
Source: domain.UserSourceDatabase,
|
|
||||||
ProviderName: "",
|
|
||||||
IsAdmin: true,
|
IsAdmin: true,
|
||||||
Firstname: "WireGuard Portal",
|
Firstname: "WireGuard Portal",
|
||||||
Lastname: "Admin",
|
Lastname: "Admin",
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ type UserManager interface {
|
|||||||
GetUser(context.Context, domain.UserIdentifier) (*domain.User, error)
|
GetUser(context.Context, domain.UserIdentifier) (*domain.User, error)
|
||||||
// RegisterUser creates a new user in the database.
|
// RegisterUser creates a new user in the database.
|
||||||
RegisterUser(ctx context.Context, user *domain.User) error
|
RegisterUser(ctx context.Context, user *domain.User) error
|
||||||
// UpdateUser updates an existing user in the database.
|
// UpdateUserInternal updates an existing user in the database.
|
||||||
UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error)
|
UpdateUserInternal(ctx context.Context, user *domain.User) (*domain.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventBus interface {
|
type EventBus interface {
|
||||||
@@ -99,7 +99,7 @@ type Authenticator struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthenticator creates a new Authenticator instance.
|
// NewAuthenticator creates a new Authenticator instance.
|
||||||
func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserManager) (
|
func NewAuthenticator(cfg *config.Auth, extUrl, basePath string, bus EventBus, users UserManager) (
|
||||||
*Authenticator,
|
*Authenticator,
|
||||||
error,
|
error,
|
||||||
) {
|
) {
|
||||||
@@ -107,7 +107,7 @@ func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserM
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
bus: bus,
|
bus: bus,
|
||||||
users: users,
|
users: users,
|
||||||
callbackUrlPrefix: fmt.Sprintf("%s/api/v0", extUrl),
|
callbackUrlPrefix: fmt.Sprintf("%s%s/api/v0", extUrl, basePath),
|
||||||
oauthAuthenticators: make(map[string]AuthenticatorOauth, len(cfg.OpenIDConnect)+len(cfg.OAuth)),
|
oauthAuthenticators: make(map[string]AuthenticatorOauth, len(cfg.OpenIDConnect)+len(cfg.OAuth)),
|
||||||
ldapAuthenticators: make(map[string]AuthenticatorLdap, len(cfg.Ldap)),
|
ldapAuthenticators: make(map[string]AuthenticatorLdap, len(cfg.Ldap)),
|
||||||
}
|
}
|
||||||
@@ -232,7 +232,7 @@ func (a *Authenticator) setupExternalAuthProviders(
|
|||||||
}
|
}
|
||||||
for i := range ldap { // LDAP
|
for i := range ldap { // LDAP
|
||||||
providerCfg := &ldap[i]
|
providerCfg := &ldap[i]
|
||||||
providerId := strings.ToLower(providerCfg.URL)
|
providerId := strings.ToLower(providerCfg.ProviderName)
|
||||||
|
|
||||||
if _, exists := a.ldapAuthenticators[providerId]; exists {
|
if _, exists := a.ldapAuthenticators[providerId]; exists {
|
||||||
// this is an unrecoverable error, we cannot register the same provider twice
|
// this is an unrecoverable error, we cannot register the same provider twice
|
||||||
@@ -354,21 +354,45 @@ func (a *Authenticator) passwordAuthentication(
|
|||||||
var ldapProvider AuthenticatorLdap
|
var ldapProvider AuthenticatorLdap
|
||||||
|
|
||||||
var userInDatabase = false
|
var userInDatabase = false
|
||||||
var userSource domain.UserSource
|
|
||||||
existingUser, err := a.users.GetUser(ctx, identifier)
|
existingUser, err := a.users.GetUser(ctx, identifier)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
userInDatabase = true
|
userInDatabase = true
|
||||||
userSource = existingUser.Source
|
|
||||||
}
|
}
|
||||||
if userInDatabase && (existingUser.IsLocked() || existingUser.IsDisabled()) {
|
if userInDatabase && (existingUser.IsLocked() || existingUser.IsDisabled()) {
|
||||||
return nil, errors.New("user is locked")
|
return nil, errors.New("user is locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !userInDatabase || userSource == domain.UserSourceLdap {
|
authOK := false
|
||||||
// search user in ldap if registration is enabled
|
if userInDatabase {
|
||||||
|
// User is already in db, search for authentication sources which support password authentication and
|
||||||
|
// validate the password.
|
||||||
|
for _, authentication := range existingUser.Authentications {
|
||||||
|
if authentication.Source == domain.UserSourceDatabase {
|
||||||
|
err := existingUser.CheckPassword(password)
|
||||||
|
if err == nil {
|
||||||
|
authOK = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if authentication.Source == domain.UserSourceLdap {
|
||||||
|
ldapProvider, ok := a.ldapAuthenticators[strings.ToLower(authentication.ProviderName)]
|
||||||
|
if !ok {
|
||||||
|
continue // ldap provider not found, skip further checks
|
||||||
|
}
|
||||||
|
err := ldapProvider.PlaintextAuthentication(identifier, password)
|
||||||
|
if err == nil {
|
||||||
|
authOK = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// User is not yet in the db, check ldap providers which have registration enabled.
|
||||||
|
// If the user is found, check the password - on success, sync it to the db.
|
||||||
for _, ldapAuth := range a.ldapAuthenticators {
|
for _, ldapAuth := range a.ldapAuthenticators {
|
||||||
if !userInDatabase && !ldapAuth.RegistrationEnabled() {
|
if !ldapAuth.RegistrationEnabled() {
|
||||||
continue
|
continue // ldap provider does not support registration, skip further checks
|
||||||
}
|
}
|
||||||
|
|
||||||
rawUserInfo, err := ldapAuth.GetUserInfo(context.Background(), identifier)
|
rawUserInfo, err := ldapAuth.GetUserInfo(context.Background(), identifier)
|
||||||
@@ -379,55 +403,39 @@ func (a *Authenticator) passwordAuthentication(
|
|||||||
}
|
}
|
||||||
continue // user not found / other ldap error
|
continue // user not found / other ldap error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// user found, check if the password is correct
|
||||||
|
err = ldapAuth.PlaintextAuthentication(identifier, password)
|
||||||
|
if err != nil {
|
||||||
|
continue // password is incorrect, skip further checks
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a new user in the db
|
||||||
ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo)
|
ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to parse ldap user info",
|
slog.Error("failed to parse ldap user info",
|
||||||
"source", ldapAuth.GetName(), "identifier", identifier, "error", err)
|
"source", ldapAuth.GetName(), "identifier", identifier, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
user, err := a.processUserInfo(ctx, ldapUserInfo, domain.UserSourceLdap, ldapProvider.GetName(), true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to process user information: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// ldap user found
|
existingUser = user
|
||||||
userSource = domain.UserSourceLdap
|
slog.Debug("created new LDAP user in db",
|
||||||
ldapProvider = ldapAuth
|
"identifier", user.Identifier, "provider", ldapProvider.GetName())
|
||||||
|
|
||||||
|
authOK = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if userSource == "" {
|
if !authOK {
|
||||||
slog.Warn("no user source found for user",
|
return nil, errors.New("failed to authenticate user")
|
||||||
"identifier", identifier, "ldapProviderCount", len(a.ldapAuthenticators), "inDb", userInDatabase)
|
|
||||||
return nil, errors.New("user not found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if userSource == domain.UserSourceLdap && ldapProvider == nil {
|
return existingUser, nil
|
||||||
slog.Warn("no ldap provider found for user",
|
|
||||||
"identifier", identifier, "ldapProviderCount", len(a.ldapAuthenticators), "inDb", userInDatabase)
|
|
||||||
return nil, errors.New("ldap provider not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch userSource {
|
|
||||||
case domain.UserSourceDatabase:
|
|
||||||
err = existingUser.CheckPassword(password)
|
|
||||||
case domain.UserSourceLdap:
|
|
||||||
err = ldapProvider.PlaintextAuthentication(identifier, password)
|
|
||||||
default:
|
|
||||||
err = errors.New("no authentication backend available")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to authenticate: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !userInDatabase {
|
|
||||||
user, err := a.processUserInfo(ctx, ldapUserInfo, domain.UserSourceLdap, ldapProvider.GetName(),
|
|
||||||
ldapProvider.RegistrationEnabled())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("unable to process user information: %w", err)
|
|
||||||
}
|
|
||||||
return user, nil
|
|
||||||
} else {
|
|
||||||
return existingUser, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// endregion password authentication
|
// endregion password authentication
|
||||||
@@ -590,17 +598,34 @@ func (a *Authenticator) registerNewUser(
|
|||||||
source domain.UserSource,
|
source domain.UserSource,
|
||||||
provider string,
|
provider string,
|
||||||
) (*domain.User, error) {
|
) (*domain.User, error) {
|
||||||
|
ctxUserInfo := domain.GetUserInfo(ctx)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
// convert user info to domain.User
|
// convert user info to domain.User
|
||||||
user := &domain.User{
|
user := &domain.User{
|
||||||
Identifier: userInfo.Identifier,
|
Identifier: userInfo.Identifier,
|
||||||
Email: userInfo.Email,
|
Email: userInfo.Email,
|
||||||
Source: source,
|
IsAdmin: false,
|
||||||
ProviderName: provider,
|
Firstname: userInfo.Firstname,
|
||||||
IsAdmin: userInfo.IsAdmin,
|
Lastname: userInfo.Lastname,
|
||||||
Firstname: userInfo.Firstname,
|
Phone: userInfo.Phone,
|
||||||
Lastname: userInfo.Lastname,
|
Department: userInfo.Department,
|
||||||
Phone: userInfo.Phone,
|
Authentications: []domain.UserAuthentication{
|
||||||
Department: userInfo.Department,
|
{
|
||||||
|
BaseModel: domain.BaseModel{
|
||||||
|
CreatedBy: ctxUserInfo.UserId(),
|
||||||
|
UpdatedBy: ctxUserInfo.UserId(),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
UserIdentifier: userInfo.Identifier,
|
||||||
|
Source: source,
|
||||||
|
ProviderName: provider,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if userInfo.AdminInfoAvailable && userInfo.IsAdmin {
|
||||||
|
user.IsAdmin = true
|
||||||
}
|
}
|
||||||
|
|
||||||
err := a.users.RegisterUser(ctx, user)
|
err := a.users.RegisterUser(ctx, user)
|
||||||
@@ -610,6 +635,7 @@ func (a *Authenticator) registerNewUser(
|
|||||||
|
|
||||||
slog.Debug("registered user from external authentication provider",
|
slog.Debug("registered user from external authentication provider",
|
||||||
"user", user.Identifier,
|
"user", user.Identifier,
|
||||||
|
"adminInfoAvailable", userInfo.AdminInfoAvailable,
|
||||||
"isAdmin", user.IsAdmin,
|
"isAdmin", user.IsAdmin,
|
||||||
"provider", source)
|
"provider", source)
|
||||||
|
|
||||||
@@ -643,6 +669,39 @@ func (a *Authenticator) updateExternalUser(
|
|||||||
return nil // user is locked or disabled, do not update
|
return nil // user is locked or disabled, do not update
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update authentication sources
|
||||||
|
foundAuthSource := false
|
||||||
|
for _, auth := range existingUser.Authentications {
|
||||||
|
if auth.Source == source && auth.ProviderName == provider {
|
||||||
|
foundAuthSource = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundAuthSource {
|
||||||
|
ctxUserInfo := domain.GetUserInfo(ctx)
|
||||||
|
now := time.Now()
|
||||||
|
existingUser.Authentications = append(existingUser.Authentications, domain.UserAuthentication{
|
||||||
|
BaseModel: domain.BaseModel{
|
||||||
|
CreatedBy: ctxUserInfo.UserId(),
|
||||||
|
UpdatedBy: ctxUserInfo.UserId(),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
UserIdentifier: existingUser.Identifier,
|
||||||
|
Source: source,
|
||||||
|
ProviderName: provider,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingUser.PersistLocalChanges {
|
||||||
|
if !foundAuthSource {
|
||||||
|
// Even if local changes are persisted, we need to save the new authentication source
|
||||||
|
_, err := a.users.UpdateUserInternal(ctx, existingUser)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
isChanged := false
|
isChanged := false
|
||||||
if existingUser.Email != userInfo.Email {
|
if existingUser.Email != userInfo.Email {
|
||||||
existingUser.Email = userInfo.Email
|
existingUser.Email = userInfo.Email
|
||||||
@@ -664,33 +723,24 @@ func (a *Authenticator) updateExternalUser(
|
|||||||
existingUser.Department = userInfo.Department
|
existingUser.Department = userInfo.Department
|
||||||
isChanged = true
|
isChanged = true
|
||||||
}
|
}
|
||||||
if existingUser.IsAdmin != userInfo.IsAdmin {
|
if userInfo.AdminInfoAvailable && existingUser.IsAdmin != userInfo.IsAdmin {
|
||||||
existingUser.IsAdmin = userInfo.IsAdmin
|
existingUser.IsAdmin = userInfo.IsAdmin
|
||||||
isChanged = true
|
isChanged = true
|
||||||
}
|
}
|
||||||
if existingUser.Source != source {
|
|
||||||
existingUser.Source = source
|
|
||||||
isChanged = true
|
|
||||||
}
|
|
||||||
if existingUser.ProviderName != provider {
|
|
||||||
existingUser.ProviderName = provider
|
|
||||||
isChanged = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isChanged {
|
if isChanged || !foundAuthSource {
|
||||||
return nil // nothing to update
|
_, err := a.users.UpdateUserInternal(ctx, existingUser)
|
||||||
}
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
_, err := a.users.UpdateUser(ctx, existingUser)
|
slog.Debug("updated user with data from external authentication provider",
|
||||||
if err != nil {
|
"user", existingUser.Identifier,
|
||||||
return fmt.Errorf("failed to update user: %w", err)
|
"adminInfoAvailable", userInfo.AdminInfoAvailable,
|
||||||
|
"isAdmin", existingUser.IsAdmin,
|
||||||
|
"provider", source)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("updated user with data from external authentication provider",
|
|
||||||
"user", existingUser.Identifier,
|
|
||||||
"isAdmin", existingUser.IsAdmin,
|
|
||||||
"provider", source)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,18 +20,7 @@ type LdapAuthenticator struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newLdapAuthenticator(_ context.Context, cfg *config.LdapProvider) (*LdapAuthenticator, error) {
|
func newLdapAuthenticator(_ context.Context, cfg *config.LdapProvider) (*LdapAuthenticator, error) {
|
||||||
var provider = &LdapAuthenticator{}
|
return &LdapAuthenticator{cfg: cfg}, nil
|
||||||
|
|
||||||
provider.cfg = cfg
|
|
||||||
|
|
||||||
dn, err := ldap.ParseDN(cfg.AdminGroupDN)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse admin group DN: %w", err)
|
|
||||||
}
|
|
||||||
provider.cfg.FieldMap = provider.getLdapFieldMapping(cfg.FieldMap)
|
|
||||||
provider.cfg.ParsedAdminGroupDN = dn
|
|
||||||
|
|
||||||
return provider, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetName returns the name of the LDAP authenticator.
|
// GetName returns the name of the LDAP authenticator.
|
||||||
@@ -138,56 +127,27 @@ func (l LdapAuthenticator) GetUserInfo(_ context.Context, userId domain.UserIden
|
|||||||
|
|
||||||
// ParseUserInfo parses the user information from the LDAP server into a domain.AuthenticatorUserInfo struct.
|
// ParseUserInfo parses the user information from the LDAP server into a domain.AuthenticatorUserInfo struct.
|
||||||
func (l LdapAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) {
|
func (l LdapAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) {
|
||||||
isAdmin, err := internal.LdapIsMemberOf(raw[l.cfg.FieldMap.GroupMembership].([][]byte), l.cfg.ParsedAdminGroupDN)
|
isAdmin := false
|
||||||
if err != nil {
|
adminInfoAvailable := false
|
||||||
return nil, fmt.Errorf("failed to check admin group: %w", err)
|
if l.cfg.FieldMap.GroupMembership != "" {
|
||||||
|
adminInfoAvailable = true
|
||||||
|
var err error
|
||||||
|
isAdmin, err = internal.LdapIsMemberOf(raw[l.cfg.FieldMap.GroupMembership].([][]byte), l.cfg.ParsedAdminGroupDN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check admin group: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
userInfo := &domain.AuthenticatorUserInfo{
|
userInfo := &domain.AuthenticatorUserInfo{
|
||||||
Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, l.cfg.FieldMap.UserIdentifier, "")),
|
Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, l.cfg.FieldMap.UserIdentifier, "")),
|
||||||
Email: internal.MapDefaultString(raw, l.cfg.FieldMap.Email, ""),
|
Email: internal.MapDefaultString(raw, l.cfg.FieldMap.Email, ""),
|
||||||
Firstname: internal.MapDefaultString(raw, l.cfg.FieldMap.Firstname, ""),
|
Firstname: internal.MapDefaultString(raw, l.cfg.FieldMap.Firstname, ""),
|
||||||
Lastname: internal.MapDefaultString(raw, l.cfg.FieldMap.Lastname, ""),
|
Lastname: internal.MapDefaultString(raw, l.cfg.FieldMap.Lastname, ""),
|
||||||
Phone: internal.MapDefaultString(raw, l.cfg.FieldMap.Phone, ""),
|
Phone: internal.MapDefaultString(raw, l.cfg.FieldMap.Phone, ""),
|
||||||
Department: internal.MapDefaultString(raw, l.cfg.FieldMap.Department, ""),
|
Department: internal.MapDefaultString(raw, l.cfg.FieldMap.Department, ""),
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
|
AdminInfoAvailable: adminInfoAvailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
return userInfo, nil
|
return userInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l LdapAuthenticator) getLdapFieldMapping(f config.LdapFields) config.LdapFields {
|
|
||||||
defaultMap := config.LdapFields{
|
|
||||||
BaseFields: config.BaseFields{
|
|
||||||
UserIdentifier: "mail",
|
|
||||||
Email: "mail",
|
|
||||||
Firstname: "givenName",
|
|
||||||
Lastname: "sn",
|
|
||||||
Phone: "telephoneNumber",
|
|
||||||
Department: "department",
|
|
||||||
},
|
|
||||||
GroupMembership: "memberOf",
|
|
||||||
}
|
|
||||||
if f.UserIdentifier != "" {
|
|
||||||
defaultMap.UserIdentifier = f.UserIdentifier
|
|
||||||
}
|
|
||||||
if f.Email != "" {
|
|
||||||
defaultMap.Email = f.Email
|
|
||||||
}
|
|
||||||
if f.Firstname != "" {
|
|
||||||
defaultMap.Firstname = f.Firstname
|
|
||||||
}
|
|
||||||
if f.Lastname != "" {
|
|
||||||
defaultMap.Lastname = f.Lastname
|
|
||||||
}
|
|
||||||
if f.Phone != "" {
|
|
||||||
defaultMap.Phone = f.Phone
|
|
||||||
}
|
|
||||||
if f.Department != "" {
|
|
||||||
defaultMap.Department = f.Department
|
|
||||||
}
|
|
||||||
if f.GroupMembership != "" {
|
|
||||||
defaultMap.GroupMembership = f.GroupMembership
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultMap
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,15 +19,16 @@ import (
|
|||||||
// PlainOauthAuthenticator is an authenticator that uses OAuth for authentication.
|
// PlainOauthAuthenticator is an authenticator that uses OAuth for authentication.
|
||||||
// User information is retrieved from the specified user info endpoint.
|
// User information is retrieved from the specified user info endpoint.
|
||||||
type PlainOauthAuthenticator struct {
|
type PlainOauthAuthenticator struct {
|
||||||
name string
|
name string
|
||||||
cfg *oauth2.Config
|
cfg *oauth2.Config
|
||||||
userInfoEndpoint string
|
userInfoEndpoint string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
userInfoMapping config.OauthFields
|
userInfoMapping config.OauthFields
|
||||||
userAdminMapping *config.OauthAdminMapping
|
userAdminMapping *config.OauthAdminMapping
|
||||||
registrationEnabled bool
|
registrationEnabled bool
|
||||||
userInfoLogging bool
|
userInfoLogging bool
|
||||||
allowedDomains []string
|
sensitiveInfoLogging bool
|
||||||
|
allowedDomains []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPlainOauthAuthenticator(
|
func newPlainOauthAuthenticator(
|
||||||
@@ -57,6 +58,7 @@ func newPlainOauthAuthenticator(
|
|||||||
provider.userAdminMapping = &cfg.AdminMapping
|
provider.userAdminMapping = &cfg.AdminMapping
|
||||||
provider.registrationEnabled = cfg.RegistrationEnabled
|
provider.registrationEnabled = cfg.RegistrationEnabled
|
||||||
provider.userInfoLogging = cfg.LogUserInfo
|
provider.userInfoLogging = cfg.LogUserInfo
|
||||||
|
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
|
||||||
provider.allowedDomains = cfg.AllowedDomains
|
provider.allowedDomains = cfg.AllowedDomains
|
||||||
|
|
||||||
return provider, nil
|
return provider, nil
|
||||||
@@ -110,6 +112,10 @@ func (p PlainOauthAuthenticator) GetUserInfo(
|
|||||||
|
|
||||||
response, err := p.client.Do(req)
|
response, err := p.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if p.sensitiveInfoLogging {
|
||||||
|
slog.Debug("OAuth: failed to get user info", "endpoint", p.userInfoEndpoint,
|
||||||
|
"token", token, "error", err)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("failed to get user info: %w", err)
|
return nil, fmt.Errorf("failed to get user info: %w", err)
|
||||||
}
|
}
|
||||||
defer internal.LogClose(response.Body)
|
defer internal.LogClose(response.Body)
|
||||||
@@ -121,11 +127,15 @@ func (p PlainOauthAuthenticator) GetUserInfo(
|
|||||||
var userFields map[string]any
|
var userFields map[string]any
|
||||||
err = json.Unmarshal(contents, &userFields)
|
err = json.Unmarshal(contents, &userFields)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if p.sensitiveInfoLogging {
|
||||||
|
slog.Debug("OAuth: failed to parse user info", "endpoint", p.userInfoEndpoint,
|
||||||
|
"token", token, "contents", contents, "error", err)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("failed to parse user info: %w", err)
|
return nil, fmt.Errorf("failed to parse user info: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.userInfoLogging {
|
if p.userInfoLogging {
|
||||||
slog.Debug("OAuth user info",
|
slog.Debug("OAuth: user info debug",
|
||||||
"source", p.name,
|
"source", p.name,
|
||||||
"info", string(contents))
|
"info", string(contents))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,15 +16,16 @@ import (
|
|||||||
|
|
||||||
// OidcAuthenticator is an authenticator for OpenID Connect providers.
|
// OidcAuthenticator is an authenticator for OpenID Connect providers.
|
||||||
type OidcAuthenticator struct {
|
type OidcAuthenticator struct {
|
||||||
name string
|
name string
|
||||||
provider *oidc.Provider
|
provider *oidc.Provider
|
||||||
verifier *oidc.IDTokenVerifier
|
verifier *oidc.IDTokenVerifier
|
||||||
cfg *oauth2.Config
|
cfg *oauth2.Config
|
||||||
userInfoMapping config.OauthFields
|
userInfoMapping config.OauthFields
|
||||||
userAdminMapping *config.OauthAdminMapping
|
userAdminMapping *config.OauthAdminMapping
|
||||||
registrationEnabled bool
|
registrationEnabled bool
|
||||||
userInfoLogging bool
|
userInfoLogging bool
|
||||||
allowedDomains []string
|
sensitiveInfoLogging bool
|
||||||
|
allowedDomains []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newOidcAuthenticator(
|
func newOidcAuthenticator(
|
||||||
@@ -58,6 +59,7 @@ func newOidcAuthenticator(
|
|||||||
provider.userAdminMapping = &cfg.AdminMapping
|
provider.userAdminMapping = &cfg.AdminMapping
|
||||||
provider.registrationEnabled = cfg.RegistrationEnabled
|
provider.registrationEnabled = cfg.RegistrationEnabled
|
||||||
provider.userInfoLogging = cfg.LogUserInfo
|
provider.userInfoLogging = cfg.LogUserInfo
|
||||||
|
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
|
||||||
provider.allowedDomains = cfg.AllowedDomains
|
provider.allowedDomains = cfg.AllowedDomains
|
||||||
|
|
||||||
return provider, nil
|
return provider, nil
|
||||||
@@ -102,24 +104,40 @@ func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token,
|
|||||||
) {
|
) {
|
||||||
rawIDToken, ok := token.Extra("id_token").(string)
|
rawIDToken, ok := token.Extra("id_token").(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if o.sensitiveInfoLogging {
|
||||||
|
slog.Debug("OIDC: token does not contain id_token", "token", token, "nonce", nonce)
|
||||||
|
}
|
||||||
return nil, errors.New("token does not contain id_token")
|
return nil, errors.New("token does not contain id_token")
|
||||||
}
|
}
|
||||||
idToken, err := o.verifier.Verify(ctx, rawIDToken)
|
idToken, err := o.verifier.Verify(ctx, rawIDToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if o.sensitiveInfoLogging {
|
||||||
|
slog.Debug("OIDC: failed to validate id_token", "token", token, "id_token", rawIDToken, "nonce", nonce,
|
||||||
|
"error",
|
||||||
|
err)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("failed to validate id_token: %w", err)
|
return nil, fmt.Errorf("failed to validate id_token: %w", err)
|
||||||
}
|
}
|
||||||
if idToken.Nonce != nonce {
|
if idToken.Nonce != nonce {
|
||||||
|
if o.sensitiveInfoLogging {
|
||||||
|
slog.Debug("OIDC: id_token nonce mismatch", "token", token, "id_token", idToken, "nonce", nonce)
|
||||||
|
}
|
||||||
return nil, errors.New("nonce mismatch")
|
return nil, errors.New("nonce mismatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenFields map[string]any
|
var tokenFields map[string]any
|
||||||
if err = idToken.Claims(&tokenFields); err != nil {
|
if err = idToken.Claims(&tokenFields); err != nil {
|
||||||
|
if o.sensitiveInfoLogging {
|
||||||
|
slog.Debug("OIDC: failed to parse extra claims", "token", token, "id_token", idToken, "nonce", nonce,
|
||||||
|
"error",
|
||||||
|
err)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("failed to parse extra claims: %w", err)
|
return nil, fmt.Errorf("failed to parse extra claims: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if o.userInfoLogging {
|
if o.userInfoLogging {
|
||||||
contents, _ := json.Marshal(tokenFields)
|
contents, _ := json.Marshal(tokenFields)
|
||||||
slog.Debug("OIDC user info",
|
slog.Debug("OIDC: user info debug",
|
||||||
"source", o.name,
|
"source", o.name,
|
||||||
"info", string(contents))
|
"info", string(contents))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ func parseOauthUserInfo(
|
|||||||
raw map[string]any,
|
raw map[string]any,
|
||||||
) (*domain.AuthenticatorUserInfo, error) {
|
) (*domain.AuthenticatorUserInfo, error) {
|
||||||
var isAdmin bool
|
var isAdmin bool
|
||||||
|
var adminInfoAvailable bool
|
||||||
|
|
||||||
// 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 != "" {
|
||||||
|
adminInfoAvailable = true
|
||||||
re := adminMapping.GetAdminValueRegex()
|
re := adminMapping.GetAdminValueRegex()
|
||||||
if re.MatchString(strings.TrimSpace(internal.MapDefaultString(raw, mapping.IsAdmin, ""))) {
|
if re.MatchString(strings.TrimSpace(internal.MapDefaultString(raw, mapping.IsAdmin, ""))) {
|
||||||
isAdmin = true
|
isAdmin = true
|
||||||
@@ -26,6 +28,7 @@ func parseOauthUserInfo(
|
|||||||
|
|
||||||
// next try to parse the user's groups
|
// next try to parse the user's groups
|
||||||
if !isAdmin && mapping.UserGroups != "" && adminMapping.AdminGroupRegex != "" {
|
if !isAdmin && mapping.UserGroups != "" && adminMapping.AdminGroupRegex != "" {
|
||||||
|
adminInfoAvailable = true
|
||||||
userGroups := internal.MapDefaultStringSlice(raw, mapping.UserGroups, nil)
|
userGroups := internal.MapDefaultStringSlice(raw, mapping.UserGroups, nil)
|
||||||
re := adminMapping.GetAdminGroupRegex()
|
re := adminMapping.GetAdminGroupRegex()
|
||||||
for _, group := range userGroups {
|
for _, group := range userGroups {
|
||||||
@@ -37,13 +40,14 @@ func parseOauthUserInfo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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, ""),
|
||||||
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, ""),
|
||||||
Department: internal.MapDefaultString(raw, mapping.Department, ""),
|
Department: internal.MapDefaultString(raw, mapping.Department, ""),
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
|
AdminInfoAvailable: adminInfoAvailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
return userInfo, nil
|
return userInfo, nil
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ type WebAuthnUserManager interface {
|
|||||||
GetUser(context.Context, domain.UserIdentifier) (*domain.User, error)
|
GetUser(context.Context, domain.UserIdentifier) (*domain.User, error)
|
||||||
// GetUserByWebAuthnCredential returns a user by its WebAuthn ID.
|
// GetUserByWebAuthnCredential returns a user by its WebAuthn ID.
|
||||||
GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error)
|
GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error)
|
||||||
// UpdateUser updates an existing user in the database.
|
// UpdateUserInternal updates an existing user in the database.
|
||||||
UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error)
|
UpdateUserInternal(ctx context.Context, user *domain.User) (*domain.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebAuthnAuthenticator struct {
|
type WebAuthnAuthenticator struct {
|
||||||
@@ -89,7 +89,7 @@ func (a *WebAuthnAuthenticator) StartWebAuthnRegistration(ctx context.Context, u
|
|||||||
|
|
||||||
if user.WebAuthnId == "" {
|
if user.WebAuthnId == "" {
|
||||||
user.GenerateWebAuthnId()
|
user.GenerateWebAuthnId()
|
||||||
user, err = a.users.UpdateUser(ctx, user)
|
user, err = a.users.UpdateUserInternal(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to store webauthn id to user: %w", err)
|
return nil, nil, fmt.Errorf("failed to store webauthn id to user: %w", err)
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,7 @@ func (a *WebAuthnAuthenticator) FinishWebAuthnRegistration(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err = a.users.UpdateUser(ctx, user)
|
user, err = a.users.UpdateUserInternal(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ func (a *WebAuthnAuthenticator) RemoveCredential(
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.RemoveCredential(credentialIdBase64)
|
user.RemoveCredential(credentialIdBase64)
|
||||||
user, err = a.users.UpdateUser(ctx, user)
|
user, err = a.users.UpdateUserInternal(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ func (a *WebAuthnAuthenticator) UpdateCredential(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err = a.users.UpdateUser(ctx, user)
|
user, err = a.users.UpdateUserInternal(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/mail"
|
||||||
|
|
||||||
"github.com/h44z/wg-portal/internal/config"
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
"github.com/h44z/wg-portal/internal/domain"
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
@@ -71,7 +72,7 @@ func NewMailManager(
|
|||||||
users UserDatabaseRepo,
|
users UserDatabaseRepo,
|
||||||
wg WireguardDatabaseRepo,
|
wg WireguardDatabaseRepo,
|
||||||
) (*Manager, error) {
|
) (*Manager, error) {
|
||||||
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle)
|
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle, cfg.Mail.TemplatesPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize template handler: %w", err)
|
return nil, fmt.Errorf("failed to initialize template handler: %w", err)
|
||||||
}
|
}
|
||||||
@@ -101,29 +102,15 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, style string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if peer.UserIdentifier == "" {
|
if peer.UserIdentifier == "" {
|
||||||
slog.Debug("skipping peer email",
|
return fmt.Errorf("peer %s has no user linked, no email is sent", peerId)
|
||||||
"peer", peerId,
|
|
||||||
"reason", "no user linked")
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := m.users.GetUser(ctx, peer.UserIdentifier)
|
email, user := m.resolveEmail(ctx, peer)
|
||||||
if err != nil {
|
if email == "" {
|
||||||
slog.Debug("skipping peer email",
|
return fmt.Errorf("peer %s has no valid email address, no email is sent", peerId)
|
||||||
"peer", peerId,
|
|
||||||
"reason", "unable to fetch user",
|
|
||||||
"error", err)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Email == "" {
|
err = m.sendPeerEmail(ctx, linkOnly, style, &user, peer)
|
||||||
slog.Debug("skipping peer email",
|
|
||||||
"peer", peerId,
|
|
||||||
"reason", "user has no mail address")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
err = m.sendPeerEmail(ctx, linkOnly, style, user, peer)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send peer email for %s: %w", peerId, err)
|
return fmt.Errorf("failed to send peer email for %s: %w", peerId, err)
|
||||||
}
|
}
|
||||||
@@ -194,3 +181,37 @@ func (m Manager) sendPeerEmail(
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Manager) resolveEmail(ctx context.Context, peer *domain.Peer) (string, domain.User) {
|
||||||
|
user, err := m.users.GetUser(ctx, peer.UserIdentifier)
|
||||||
|
if err != nil {
|
||||||
|
if m.cfg.Mail.AllowPeerEmail {
|
||||||
|
_, err := mail.ParseAddress(string(peer.UserIdentifier)) // test if the user identifier is a valid email address
|
||||||
|
if err == nil {
|
||||||
|
slog.Debug("peer email: using user-identifier as email",
|
||||||
|
"peer", peer.Identifier, "email", peer.UserIdentifier)
|
||||||
|
return string(peer.UserIdentifier), domain.User{}
|
||||||
|
} else {
|
||||||
|
slog.Debug("peer email: skipping peer email",
|
||||||
|
"peer", peer.Identifier,
|
||||||
|
"reason", "peer has no user linked and user-identifier is not a valid email address")
|
||||||
|
return "", domain.User{}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog.Debug("peer email: skipping peer email",
|
||||||
|
"peer", peer.Identifier,
|
||||||
|
"reason", "user has no user linked")
|
||||||
|
return "", domain.User{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Email == "" {
|
||||||
|
slog.Debug("peer email: skipping peer email",
|
||||||
|
"peer", peer.Identifier,
|
||||||
|
"reason", "user has no mail address")
|
||||||
|
return "", domain.User{}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("peer email: using user email", "peer", peer.Identifier, "email", user.Email)
|
||||||
|
return user.Email, *user
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
htmlTemplate "html/template"
|
htmlTemplate "html/template"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"github.com/h44z/wg-portal/internal/domain"
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
@@ -22,15 +26,50 @@ type TemplateHandler struct {
|
|||||||
textTemplates *template.Template
|
textTemplates *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error) {
|
func newTemplateHandler(portalUrl, portalName string, basePath string) (*TemplateHandler, error) {
|
||||||
|
// Always parse embedded defaults first
|
||||||
htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml")
|
htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse html template files: %w", err)
|
return nil, fmt.Errorf("failed to parse embedded html template files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
txtTemplateCache, err := template.New("Txt").ParseFS(TemplateFiles, "tpl_files/*.gotpl")
|
txtTemplateCache, err := template.New("Txt").ParseFS(TemplateFiles, "tpl_files/*.gotpl")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse text template files: %w", err)
|
return nil, fmt.Errorf("failed to parse embedded text template files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a basePath is provided, ensure existence, populate if empty, then parse to override
|
||||||
|
if basePath != "" {
|
||||||
|
if err := os.MkdirAll(basePath, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create templates base directory %s: %w", basePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasTemplates, err := dirHasTemplates(basePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to inspect templates directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no templates present, copy embedded defaults to directory
|
||||||
|
if !hasTemplates {
|
||||||
|
if err := copyEmbeddedTemplates(basePath); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to populate templates directory: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse files from basePath to override embedded ones.
|
||||||
|
// Only parse when matches exist to allow partial overrides without errors.
|
||||||
|
if matches, _ := filepath.Glob(filepath.Join(basePath, "*.gohtml")); len(matches) > 0 {
|
||||||
|
slog.Debug("parsing html email templates from base path", "base-path", basePath, "files", matches)
|
||||||
|
if htmlTemplateCache, err = htmlTemplateCache.ParseFiles(matches...); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse html templates from base path: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches, _ := filepath.Glob(filepath.Join(basePath, "*.gotpl")); len(matches) > 0 {
|
||||||
|
slog.Debug("parsing text email templates from base path", "base-path", basePath, "files", matches)
|
||||||
|
if txtTemplateCache, err = txtTemplateCache.ParseFiles(matches...); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse text templates from base path: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := &TemplateHandler{
|
handler := &TemplateHandler{
|
||||||
@@ -43,24 +82,71 @@ func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error)
|
|||||||
return handler, nil
|
return handler, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dirHasTemplates checks whether directory contains any .gohtml or .gotpl files.
|
||||||
|
func dirHasTemplates(basePath string) (bool, error) {
|
||||||
|
entries, err := os.ReadDir(basePath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(e.Name())
|
||||||
|
if ext == ".gohtml" || ext == ".gotpl" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEmbeddedTemplates writes embedded templates into basePath.
|
||||||
|
func copyEmbeddedTemplates(basePath string) error {
|
||||||
|
list, err := fs.ReadDir(TemplateFiles, "tpl_files")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range list {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
// Only copy known template extensions
|
||||||
|
if ext := filepath.Ext(name); ext != ".gohtml" && ext != ".gotpl" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := TemplateFiles.ReadFile(filepath.Join("tpl_files", name))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := filepath.Join(basePath, name)
|
||||||
|
if err := os.WriteFile(out, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetConfigMail returns the text and html template for the mail with a link.
|
// GetConfigMail returns the text and html template for the mail with a link.
|
||||||
func (c TemplateHandler) GetConfigMail(user *domain.User, link string) (io.Reader, io.Reader, error) {
|
func (c TemplateHandler) GetConfigMail(user *domain.User, link string) (io.Reader, io.Reader, error) {
|
||||||
var tplBuff bytes.Buffer
|
var tplBuff bytes.Buffer
|
||||||
var htmlTplBuff bytes.Buffer
|
var htmlTplBuff bytes.Buffer
|
||||||
|
|
||||||
err := c.textTemplates.ExecuteTemplate(&tplBuff, "mail_with_link.gotpl", map[string]any{
|
err := c.textTemplates.ExecuteTemplate(&tplBuff, "mail_with_link.gotpl", map[string]any{
|
||||||
"User": user,
|
"User": user,
|
||||||
"Link": link,
|
"Link": link,
|
||||||
"PortalUrl": c.portalUrl,
|
"PortalUrl": c.portalUrl,
|
||||||
|
"PortalName": c.portalName,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gotpl: %w", err)
|
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gotpl: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.htmlTemplates.ExecuteTemplate(&htmlTplBuff, "mail_with_link.gohtml", map[string]any{
|
err = c.htmlTemplates.ExecuteTemplate(&htmlTplBuff, "mail_with_link.gohtml", map[string]any{
|
||||||
"User": user,
|
"User": user,
|
||||||
"Link": link,
|
"Link": link,
|
||||||
"PortalUrl": c.portalUrl,
|
"PortalUrl": c.portalUrl,
|
||||||
|
"PortalName": c.portalName,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gohtml: %w", err)
|
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gohtml: %w", err)
|
||||||
|
|||||||
@@ -144,8 +144,6 @@ func migrateV1Users(oldDb, newDb *gorm.DB) error {
|
|||||||
},
|
},
|
||||||
Identifier: domain.UserIdentifier(oldUser.Email),
|
Identifier: domain.UserIdentifier(oldUser.Email),
|
||||||
Email: oldUser.Email,
|
Email: oldUser.Email,
|
||||||
Source: domain.UserSource(oldUser.Source),
|
|
||||||
ProviderName: "",
|
|
||||||
IsAdmin: oldUser.IsAdmin,
|
IsAdmin: oldUser.IsAdmin,
|
||||||
Firstname: oldUser.Firstname,
|
Firstname: oldUser.Firstname,
|
||||||
Lastname: oldUser.Lastname,
|
Lastname: oldUser.Lastname,
|
||||||
@@ -159,11 +157,25 @@ func migrateV1Users(oldDb, newDb *gorm.DB) error {
|
|||||||
LockedReason: "",
|
LockedReason: "",
|
||||||
LinkedPeerCount: 0,
|
LinkedPeerCount: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := newDb.Create(&newUser).Error; err != nil {
|
if err := newDb.Create(&newUser).Error; err != nil {
|
||||||
return fmt.Errorf("failed to migrate user %s: %w", oldUser.Email, err)
|
return fmt.Errorf("failed to migrate user %s: %w", oldUser.Email, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authentication := domain.UserAuthentication{
|
||||||
|
BaseModel: domain.BaseModel{
|
||||||
|
CreatedBy: domain.CtxSystemV1Migrator,
|
||||||
|
UpdatedBy: domain.CtxSystemV1Migrator,
|
||||||
|
CreatedAt: oldUser.CreatedAt,
|
||||||
|
UpdatedAt: oldUser.UpdatedAt,
|
||||||
|
},
|
||||||
|
UserIdentifier: domain.UserIdentifier(oldUser.Email),
|
||||||
|
Source: domain.UserSource(oldUser.Source),
|
||||||
|
ProviderName: "", // unknown
|
||||||
|
}
|
||||||
|
if err := newDb.Create(&authentication).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to migrate user-authentication %s: %w", oldUser.Email, err)
|
||||||
|
}
|
||||||
|
|
||||||
slog.Debug("user migrated successfully", "identifier", newUser.Identifier)
|
slog.Debug("user migrated successfully", "identifier", newUser.Identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,8 +358,6 @@ func migrateV1Peers(oldDb, newDb *gorm.DB) error {
|
|||||||
},
|
},
|
||||||
Identifier: domain.UserIdentifier(oldPeer.Email),
|
Identifier: domain.UserIdentifier(oldPeer.Email),
|
||||||
Email: oldPeer.Email,
|
Email: oldPeer.Email,
|
||||||
Source: domain.UserSourceDatabase,
|
|
||||||
ProviderName: "",
|
|
||||||
IsAdmin: false,
|
IsAdmin: false,
|
||||||
Locked: &now,
|
Locked: &now,
|
||||||
LockedReason: domain.DisabledReasonMigrationDummy,
|
LockedReason: domain.DisabledReasonMigrationDummy,
|
||||||
@@ -358,6 +368,21 @@ func migrateV1Peers(oldDb, newDb *gorm.DB) error {
|
|||||||
return fmt.Errorf("failed to migrate dummy user %s: %w", oldPeer.Email, err)
|
return fmt.Errorf("failed to migrate dummy user %s: %w", oldPeer.Email, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authentication := domain.UserAuthentication{
|
||||||
|
BaseModel: domain.BaseModel{
|
||||||
|
CreatedBy: domain.CtxSystemV1Migrator,
|
||||||
|
UpdatedBy: domain.CtxSystemV1Migrator,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
UserIdentifier: domain.UserIdentifier(oldPeer.Email),
|
||||||
|
Source: domain.UserSourceDatabase,
|
||||||
|
ProviderName: "", // unknown
|
||||||
|
}
|
||||||
|
if err := newDb.Create(&authentication).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to migrate dummy user-authentication %s: %w", oldPeer.Email, err)
|
||||||
|
}
|
||||||
|
|
||||||
slog.Debug("dummy user migrated successfully", "identifier", user.Identifier)
|
slog.Debug("dummy user migrated successfully", "identifier", user.Identifier)
|
||||||
}
|
}
|
||||||
newPeer := domain.Peer{
|
newPeer := domain.Peer{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package users
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ func convertRawLdapUser(
|
|||||||
return nil, fmt.Errorf("failed to check admin group: %w", err)
|
return nil, fmt.Errorf("failed to check admin group: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uid := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, ""))
|
||||||
|
|
||||||
return &domain.User{
|
return &domain.User{
|
||||||
BaseModel: domain.BaseModel{
|
BaseModel: domain.BaseModel{
|
||||||
CreatedBy: domain.CtxSystemLdapSyncer,
|
CreatedBy: domain.CtxSystemLdapSyncer,
|
||||||
@@ -32,18 +35,23 @@ func convertRawLdapUser(
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
},
|
},
|
||||||
Identifier: domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, "")),
|
Identifier: uid,
|
||||||
Email: strings.ToLower(internal.MapDefaultString(rawUser, fields.Email, "")),
|
Email: strings.ToLower(internal.MapDefaultString(rawUser, fields.Email, "")),
|
||||||
Source: domain.UserSourceLdap,
|
IsAdmin: isAdmin,
|
||||||
ProviderName: providerName,
|
Authentications: []domain.UserAuthentication{
|
||||||
IsAdmin: isAdmin,
|
{
|
||||||
Firstname: internal.MapDefaultString(rawUser, fields.Firstname, ""),
|
UserIdentifier: uid,
|
||||||
Lastname: internal.MapDefaultString(rawUser, fields.Lastname, ""),
|
Source: domain.UserSourceLdap,
|
||||||
Phone: internal.MapDefaultString(rawUser, fields.Phone, ""),
|
ProviderName: providerName,
|
||||||
Department: internal.MapDefaultString(rawUser, fields.Department, ""),
|
},
|
||||||
Notes: "",
|
},
|
||||||
Password: "",
|
Firstname: internal.MapDefaultString(rawUser, fields.Firstname, ""),
|
||||||
Disabled: nil,
|
Lastname: internal.MapDefaultString(rawUser, fields.Lastname, ""),
|
||||||
|
Phone: internal.MapDefaultString(rawUser, fields.Phone, ""),
|
||||||
|
Department: internal.MapDefaultString(rawUser, fields.Department, ""),
|
||||||
|
Notes: "",
|
||||||
|
Password: "",
|
||||||
|
Disabled: nil,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +80,9 @@ func userChangedInLdap(dbUser, ldapUser *domain.User) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if dbUser.ProviderName != ldapUser.ProviderName {
|
if !slices.ContainsFunc(dbUser.Authentications, func(authentication domain.UserAuthentication) bool {
|
||||||
|
return authentication.Source == ldapUser.Authentications[0].Source
|
||||||
|
}) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
239
internal/app/users/ldap_sync.go
Normal file
239
internal/app/users/ldap_sync.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package users
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
|
||||||
|
"github.com/h44z/wg-portal/internal"
|
||||||
|
"github.com/h44z/wg-portal/internal/app"
|
||||||
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Manager) runLdapSynchronizationService(ctx context.Context) {
|
||||||
|
ctx = domain.SetUserInfo(ctx, domain.LdapSyncContextUserInfo()) // switch to service context for LDAP sync
|
||||||
|
|
||||||
|
for _, ldapCfg := range m.cfg.Auth.Ldap { // LDAP Auth providers
|
||||||
|
go func(cfg config.LdapProvider) {
|
||||||
|
syncInterval := cfg.SyncInterval
|
||||||
|
if syncInterval == 0 {
|
||||||
|
slog.Debug("sync disabled for LDAP server", "provider", cfg.ProviderName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// perform initial sync
|
||||||
|
err := m.synchronizeLdapUsers(ctx, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Debug("initial LDAP user sync completed", "provider", cfg.ProviderName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// start periodic sync
|
||||||
|
running := true
|
||||||
|
for running {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
running = false
|
||||||
|
continue
|
||||||
|
case <-time.After(syncInterval):
|
||||||
|
// select blocks until one of the cases evaluate to true
|
||||||
|
}
|
||||||
|
|
||||||
|
err := m.synchronizeLdapUsers(ctx, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(ldapCfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) synchronizeLdapUsers(ctx context.Context, provider *config.LdapProvider) error {
|
||||||
|
slog.Debug("starting to synchronize users", "provider", provider.ProviderName)
|
||||||
|
|
||||||
|
dn, err := ldap.ParseDN(provider.AdminGroupDN)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse admin group DN: %w", err)
|
||||||
|
}
|
||||||
|
provider.ParsedAdminGroupDN = dn
|
||||||
|
|
||||||
|
conn, err := internal.LdapConnect(provider)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to setup LDAP connection: %w", err)
|
||||||
|
}
|
||||||
|
defer internal.LdapDisconnect(conn)
|
||||||
|
|
||||||
|
rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, provider.SyncFilter, &provider.FieldMap)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("fetched raw ldap users", "count", len(rawUsers), "provider", provider.ProviderName)
|
||||||
|
|
||||||
|
// Update existing LDAP users
|
||||||
|
err = m.updateLdapUsers(ctx, provider, rawUsers, &provider.FieldMap, provider.ParsedAdminGroupDN)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable missing LDAP users
|
||||||
|
if provider.DisableMissing {
|
||||||
|
err = m.disableMissingLdapUsers(ctx, provider.ProviderName, rawUsers, &provider.FieldMap)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) updateLdapUsers(
|
||||||
|
ctx context.Context,
|
||||||
|
provider *config.LdapProvider,
|
||||||
|
rawUsers []internal.RawLdapUser,
|
||||||
|
fields *config.LdapFields,
|
||||||
|
adminGroupDN *ldap.DN,
|
||||||
|
) error {
|
||||||
|
for _, rawUser := range rawUsers {
|
||||||
|
user, err := convertRawLdapUser(provider.ProviderName, rawUser, fields, adminGroupDN)
|
||||||
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider.SyncLogUserInfo {
|
||||||
|
slog.Debug("ldap user data",
|
||||||
|
"raw-user", rawUser, "user", user.Identifier,
|
||||||
|
"is-admin", user.IsAdmin, "provider", provider.ProviderName)
|
||||||
|
}
|
||||||
|
|
||||||
|
existingUser, err := m.users.GetUser(ctx, user.Identifier)
|
||||||
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
tctx = domain.SetUserInfo(tctx, domain.SystemAdminContextUserInfo())
|
||||||
|
|
||||||
|
if existingUser == nil {
|
||||||
|
// create new user
|
||||||
|
slog.Debug("creating new user from provider", "user", user.Identifier, "provider", provider.ProviderName)
|
||||||
|
|
||||||
|
_, err := m.create(tctx, user)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return fmt.Errorf("create error for user id %s: %w", user.Identifier, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// update existing user
|
||||||
|
if provider.AutoReEnable && existingUser.DisabledReason == domain.DisabledReasonLdapMissing {
|
||||||
|
user.Disabled = nil
|
||||||
|
user.DisabledReason = ""
|
||||||
|
} else {
|
||||||
|
user.Disabled = existingUser.Disabled
|
||||||
|
user.DisabledReason = existingUser.DisabledReason
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingUser.PersistLocalChanges {
|
||||||
|
cancel()
|
||||||
|
continue // skip synchronization for this user
|
||||||
|
}
|
||||||
|
|
||||||
|
if userChangedInLdap(existingUser, user) {
|
||||||
|
syncedUser, err := m.users.GetUser(ctx, user.Identifier)
|
||||||
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
cancel()
|
||||||
|
return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)
|
||||||
|
}
|
||||||
|
syncedUser.UpdatedAt = time.Now()
|
||||||
|
syncedUser.UpdatedBy = domain.CtxSystemLdapSyncer
|
||||||
|
syncedUser.MergeAuthSources(user.Authentications...)
|
||||||
|
syncedUser.Email = user.Email
|
||||||
|
syncedUser.Firstname = user.Firstname
|
||||||
|
syncedUser.Lastname = user.Lastname
|
||||||
|
syncedUser.Phone = user.Phone
|
||||||
|
syncedUser.Department = user.Department
|
||||||
|
syncedUser.IsAdmin = user.IsAdmin
|
||||||
|
syncedUser.Disabled = user.Disabled
|
||||||
|
syncedUser.DisabledReason = user.DisabledReason
|
||||||
|
|
||||||
|
_, err = m.update(tctx, existingUser, syncedUser, false)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return fmt.Errorf("update error for user id %s: %w", user.Identifier, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) disableMissingLdapUsers(
|
||||||
|
ctx context.Context,
|
||||||
|
providerName string,
|
||||||
|
rawUsers []internal.RawLdapUser,
|
||||||
|
fields *config.LdapFields,
|
||||||
|
) error {
|
||||||
|
allUsers, err := m.users.GetAllUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, user := range allUsers {
|
||||||
|
userHasAuthSource := false
|
||||||
|
for _, auth := range user.Authentications {
|
||||||
|
if auth.Source == domain.UserSourceLdap && auth.ProviderName == providerName {
|
||||||
|
userHasAuthSource = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !userHasAuthSource {
|
||||||
|
continue // ignore non ldap users
|
||||||
|
}
|
||||||
|
if user.IsDisabled() {
|
||||||
|
continue // ignore deactivated
|
||||||
|
}
|
||||||
|
if user.PersistLocalChanges {
|
||||||
|
continue // skip sync for this user
|
||||||
|
}
|
||||||
|
|
||||||
|
existsInLDAP := false
|
||||||
|
for _, rawUser := range rawUsers {
|
||||||
|
userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, ""))
|
||||||
|
if user.Identifier == userId {
|
||||||
|
existsInLDAP = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if existsInLDAP {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("user is missing in ldap provider, disabling", "user", user.Identifier, "provider", providerName)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
user.Disabled = &now
|
||||||
|
user.DisabledReason = domain.DisabledReasonLdapMissing
|
||||||
|
|
||||||
|
err := m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
|
||||||
|
u.Disabled = user.Disabled
|
||||||
|
u.DisabledReason = user.DisabledReason
|
||||||
|
return u, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("disable error for user id %s: %w", user.Identifier, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.bus.Publish(app.TopicUserDisabled, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -4,15 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"math"
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-ldap/ldap/v3"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/h44z/wg-portal/internal"
|
|
||||||
"github.com/h44z/wg-portal/internal/app"
|
"github.com/h44z/wg-portal/internal/app"
|
||||||
"github.com/h44z/wg-portal/internal/config"
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
"github.com/h44z/wg-portal/internal/domain"
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
@@ -79,7 +76,7 @@ func (m Manager) RegisterUser(ctx context.Context, user *domain.User) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
createdUser, err := m.CreateUser(ctx, user)
|
createdUser, err := m.create(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -101,20 +98,11 @@ func (m Manager) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := m.users.GetUser(ctx, id)
|
return m.getUser(ctx, id)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("unable to load user %s: %w", id, err)
|
|
||||||
}
|
|
||||||
peers, _ := m.peers.GetUserPeers(ctx, id) // ignore error, list will be empty in error case
|
|
||||||
|
|
||||||
user.LinkedPeerCount = len(peers)
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByEmail returns the user with the given email address.
|
// GetUserByEmail returns the user with the given email address.
|
||||||
func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||||
|
|
||||||
user, err := m.users.GetUserByEmail(ctx, email)
|
user, err := m.users.GetUserByEmail(ctx, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unable to load user for email %s: %w", email, err)
|
return nil, fmt.Errorf("unable to load user for email %s: %w", email, err)
|
||||||
@@ -124,16 +112,11 @@ func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case
|
return m.enrichUser(ctx, user), nil
|
||||||
|
|
||||||
user.LinkedPeerCount = len(peers)
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByWebAuthnCredential returns the user for the given WebAuthn credential.
|
// GetUserByWebAuthnCredential returns the user for the given WebAuthn credential.
|
||||||
func (m Manager) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) {
|
func (m Manager) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) {
|
||||||
|
|
||||||
user, err := m.users.GetUserByWebAuthnCredential(ctx, credentialIdBase64)
|
user, err := m.users.GetUserByWebAuthnCredential(ctx, credentialIdBase64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unable to load user for webauthn credential %s: %w", credentialIdBase64, err)
|
return nil, fmt.Errorf("unable to load user for webauthn credential %s: %w", credentialIdBase64, err)
|
||||||
@@ -143,11 +126,7 @@ func (m Manager) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBa
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case
|
return m.enrichUser(ctx, user), nil
|
||||||
|
|
||||||
user.LinkedPeerCount = len(peers)
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllUsers returns all users.
|
// GetAllUsers returns all users.
|
||||||
@@ -169,8 +148,7 @@ func (m Manager) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for user := range ch {
|
for user := range ch {
|
||||||
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case
|
m.enrichUser(ctx, user)
|
||||||
user.LinkedPeerCount = len(peers)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -194,77 +172,29 @@ func (m Manager) UpdateUser(ctx context.Context, user *domain.User) (*domain.Use
|
|||||||
return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
|
return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.validateModifications(ctx, existingUser, user); err != nil {
|
user.CopyCalculatedAttributes(existingUser, true) // ensure that crucial attributes stay the same
|
||||||
return nil, fmt.Errorf("update not allowed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user.CopyCalculatedAttributes(existingUser)
|
return m.update(ctx, existingUser, user, true)
|
||||||
err = user.HashPassword()
|
}
|
||||||
|
|
||||||
|
// UpdateUserInternal updates the user with the given identifier. This function must never be called from external.
|
||||||
|
// This function allows to override authentications and webauthn credentials.
|
||||||
|
func (m Manager) UpdateUserInternal(ctx context.Context, user *domain.User) (*domain.User, error) {
|
||||||
|
existingUser, err := m.users.GetUser(ctx, user.Identifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
|
||||||
}
|
|
||||||
if user.Password == "" { // keep old password
|
|
||||||
user.Password = existingUser.Password
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = m.users.SaveUser(ctx, existingUser.Identifier, func(u *domain.User) (*domain.User, error) {
|
return m.update(ctx, existingUser, user, false)
|
||||||
user.CopyCalculatedAttributes(u)
|
|
||||||
return user, nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("update failure: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.bus.Publish(app.TopicUserUpdated, *user)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case !existingUser.IsDisabled() && user.IsDisabled():
|
|
||||||
m.bus.Publish(app.TopicUserDisabled, *user)
|
|
||||||
case existingUser.IsDisabled() && !user.IsDisabled():
|
|
||||||
m.bus.Publish(app.TopicUserEnabled, *user)
|
|
||||||
}
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser creates a new user.
|
// CreateUser creates a new user.
|
||||||
func (m Manager) CreateUser(ctx context.Context, user *domain.User) (*domain.User, error) {
|
func (m Manager) CreateUser(ctx context.Context, user *domain.User) (*domain.User, error) {
|
||||||
if user.Identifier == "" {
|
|
||||||
return nil, errors.New("missing user identifier")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
existingUser, err := m.users.GetUser(ctx, user.Identifier)
|
return m.create(ctx, user)
|
||||||
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
|
||||||
return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
|
|
||||||
}
|
|
||||||
if existingUser != nil {
|
|
||||||
return nil, errors.Join(fmt.Errorf("user %s already exists", user.Identifier), domain.ErrDuplicateEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.validateCreation(ctx, user); err != nil {
|
|
||||||
return nil, fmt.Errorf("creation not allowed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = user.HashPassword()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
|
|
||||||
user.CopyCalculatedAttributes(u)
|
|
||||||
return user, nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("creation failure: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.bus.Publish(app.TopicUserCreated, *user)
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser deletes the user with the given identifier.
|
// DeleteUser deletes the user with the given identifier.
|
||||||
@@ -307,15 +237,10 @@ func (m Manager) ActivateApi(ctx context.Context, id domain.UserIdentifier) (*do
|
|||||||
user.ApiToken = uuid.New().String()
|
user.ApiToken = uuid.New().String()
|
||||||
user.ApiTokenCreated = &now
|
user.ApiTokenCreated = &now
|
||||||
|
|
||||||
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
|
user, err = m.update(ctx, user, user, true) // self-update
|
||||||
user.CopyCalculatedAttributes(u)
|
|
||||||
return user, nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("update failure: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.bus.Publish(app.TopicUserUpdated, *user)
|
|
||||||
m.bus.Publish(app.TopicUserApiEnabled, *user)
|
m.bus.Publish(app.TopicUserApiEnabled, *user)
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
@@ -335,15 +260,10 @@ func (m Manager) DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*
|
|||||||
user.ApiToken = ""
|
user.ApiToken = ""
|
||||||
user.ApiTokenCreated = nil
|
user.ApiTokenCreated = nil
|
||||||
|
|
||||||
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
|
user, err = m.update(ctx, user, user, true) // self-update
|
||||||
user.CopyCalculatedAttributes(u)
|
|
||||||
return user, nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("update failure: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.bus.Publish(app.TopicUserUpdated, *user)
|
|
||||||
m.bus.Publish(app.TopicUserApiDisabled, *user)
|
m.bus.Publish(app.TopicUserApiDisabled, *user)
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
@@ -380,10 +300,6 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Use
|
|||||||
return fmt.Errorf("cannot lock own user: %w", domain.ErrInvalidData)
|
return fmt.Errorf("cannot lock own user: %w", domain.ErrInvalidData)
|
||||||
}
|
}
|
||||||
|
|
||||||
if old.Source != new.Source {
|
|
||||||
return fmt.Errorf("cannot change user source: %w", domain.ErrInvalidData)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,14 +330,19 @@ func (m Manager) validateCreation(ctx context.Context, new *domain.User) error {
|
|||||||
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
|
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(new.Authentications) != 1 {
|
||||||
|
return fmt.Errorf("invalid number of authentications: %d, expected 1: %w",
|
||||||
|
len(new.Authentications), domain.ErrInvalidData)
|
||||||
|
}
|
||||||
|
|
||||||
// Admins are allowed to create users for arbitrary sources.
|
// Admins are allowed to create users for arbitrary sources.
|
||||||
if new.Source != domain.UserSourceDatabase && !currentUser.IsAdmin {
|
if new.Authentications[0].Source != domain.UserSourceDatabase && !currentUser.IsAdmin {
|
||||||
return fmt.Errorf("invalid user source: %s, only %s is allowed: %w",
|
return fmt.Errorf("invalid user source: %s, only %s is allowed: %w",
|
||||||
new.Source, domain.UserSourceDatabase, domain.ErrInvalidData)
|
new.Authentications[0].Source, domain.UserSourceDatabase, domain.ErrInvalidData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// database users must have a password
|
// database users must have a password
|
||||||
if new.Source == domain.UserSourceDatabase && string(new.Password) == "" {
|
if new.Authentications[0].Source == domain.UserSourceDatabase && string(new.Password) == "" {
|
||||||
return fmt.Errorf("missing password: %w", domain.ErrInvalidData)
|
return fmt.Errorf("missing password: %w", domain.ErrInvalidData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,208 +381,112 @@ func (m Manager) validateApiChange(ctx context.Context, user *domain.User) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) runLdapSynchronizationService(ctx context.Context) {
|
// region internal-modifiers
|
||||||
ctx = domain.SetUserInfo(ctx, domain.LdapSyncContextUserInfo()) // switch to service context for LDAP sync
|
|
||||||
|
|
||||||
for _, ldapCfg := range m.cfg.Auth.Ldap { // LDAP Auth providers
|
func (m Manager) enrichUser(ctx context.Context, user *domain.User) *domain.User {
|
||||||
go func(cfg config.LdapProvider) {
|
if user == nil {
|
||||||
syncInterval := cfg.SyncInterval
|
return nil
|
||||||
if syncInterval == 0 {
|
|
||||||
slog.Debug("sync disabled for LDAP server", "provider", cfg.ProviderName)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// perform initial sync
|
|
||||||
err := m.synchronizeLdapUsers(ctx, &cfg)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
|
|
||||||
} else {
|
|
||||||
slog.Debug("initial LDAP user sync completed", "provider", cfg.ProviderName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// start periodic sync
|
|
||||||
running := true
|
|
||||||
for running {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
running = false
|
|
||||||
continue
|
|
||||||
case <-time.After(syncInterval):
|
|
||||||
// select blocks until one of the cases evaluate to true
|
|
||||||
}
|
|
||||||
|
|
||||||
err := m.synchronizeLdapUsers(ctx, &cfg)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to synchronize LDAP users", "provider", cfg.ProviderName, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(ldapCfg)
|
|
||||||
}
|
}
|
||||||
|
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case
|
||||||
|
user.LinkedPeerCount = len(peers)
|
||||||
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) synchronizeLdapUsers(ctx context.Context, provider *config.LdapProvider) error {
|
func (m Manager) getUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
|
||||||
slog.Debug("starting to synchronize users", "provider", provider.ProviderName)
|
user, err := m.users.GetUser(ctx, id)
|
||||||
|
|
||||||
dn, err := ldap.ParseDN(provider.AdminGroupDN)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse admin group DN: %w", err)
|
return nil, fmt.Errorf("unable to load user %s: %w", id, err)
|
||||||
}
|
}
|
||||||
provider.ParsedAdminGroupDN = dn
|
return m.enrichUser(ctx, user), nil
|
||||||
|
|
||||||
conn, err := internal.LdapConnect(provider)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to setup LDAP connection: %w", err)
|
|
||||||
}
|
|
||||||
defer internal.LdapDisconnect(conn)
|
|
||||||
|
|
||||||
rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, provider.SyncFilter, &provider.FieldMap)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Debug("fetched raw ldap users", "count", len(rawUsers), "provider", provider.ProviderName)
|
|
||||||
|
|
||||||
// Update existing LDAP users
|
|
||||||
err = m.updateLdapUsers(ctx, provider, rawUsers, &provider.FieldMap, provider.ParsedAdminGroupDN)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable missing LDAP users
|
|
||||||
if provider.DisableMissing {
|
|
||||||
err = m.disableMissingLdapUsers(ctx, provider.ProviderName, rawUsers, &provider.FieldMap)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) updateLdapUsers(
|
func (m Manager) update(ctx context.Context, existingUser, user *domain.User, keepAuthentications bool) (
|
||||||
ctx context.Context,
|
*domain.User,
|
||||||
provider *config.LdapProvider,
|
error,
|
||||||
rawUsers []internal.RawLdapUser,
|
) {
|
||||||
fields *config.LdapFields,
|
if err := m.validateModifications(ctx, existingUser, user); err != nil {
|
||||||
adminGroupDN *ldap.DN,
|
return nil, fmt.Errorf("update not allowed: %w", err)
|
||||||
) error {
|
|
||||||
for _, rawUser := range rawUsers {
|
|
||||||
user, err := convertRawLdapUser(provider.ProviderName, rawUser, fields, adminGroupDN)
|
|
||||||
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
|
||||||
return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err)
|
|
||||||
}
|
|
||||||
|
|
||||||
existingUser, err := m.users.GetUser(ctx, user.Identifier)
|
|
||||||
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
|
||||||
return fmt.Errorf("find error for user id %s: %w", user.Identifier, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
||||||
tctx = domain.SetUserInfo(tctx, domain.SystemAdminContextUserInfo())
|
|
||||||
|
|
||||||
if existingUser == nil {
|
|
||||||
// create new user
|
|
||||||
slog.Debug("creating new user from provider", "user", user.Identifier, "provider", provider.ProviderName)
|
|
||||||
|
|
||||||
_, err := m.CreateUser(tctx, user)
|
|
||||||
if err != nil {
|
|
||||||
cancel()
|
|
||||||
return fmt.Errorf("create error for user id %s: %w", user.Identifier, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// update existing user
|
|
||||||
if provider.AutoReEnable && existingUser.DisabledReason == domain.DisabledReasonLdapMissing {
|
|
||||||
user.Disabled = nil
|
|
||||||
user.DisabledReason = ""
|
|
||||||
} else {
|
|
||||||
user.Disabled = existingUser.Disabled
|
|
||||||
user.DisabledReason = existingUser.DisabledReason
|
|
||||||
}
|
|
||||||
if existingUser.Source == domain.UserSourceLdap && userChangedInLdap(existingUser, user) {
|
|
||||||
err := m.users.SaveUser(tctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
|
|
||||||
u.UpdatedAt = time.Now()
|
|
||||||
u.UpdatedBy = domain.CtxSystemLdapSyncer
|
|
||||||
u.Source = user.Source
|
|
||||||
u.ProviderName = user.ProviderName
|
|
||||||
u.Email = user.Email
|
|
||||||
u.Firstname = user.Firstname
|
|
||||||
u.Lastname = user.Lastname
|
|
||||||
u.Phone = user.Phone
|
|
||||||
u.Department = user.Department
|
|
||||||
u.IsAdmin = user.IsAdmin
|
|
||||||
u.Disabled = nil
|
|
||||||
u.DisabledReason = ""
|
|
||||||
|
|
||||||
return u, nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
cancel()
|
|
||||||
return fmt.Errorf("update error for user id %s: %w", user.Identifier, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if existingUser.IsDisabled() && !user.IsDisabled() {
|
|
||||||
m.bus.Publish(app.TopicUserEnabled, *user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
err := user.HashPassword()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user.Password == "" { // keep old password
|
||||||
|
user.Password = existingUser.Password
|
||||||
|
}
|
||||||
|
|
||||||
|
err = m.users.SaveUser(ctx, existingUser.Identifier, func(u *domain.User) (*domain.User, error) {
|
||||||
|
user.CopyCalculatedAttributes(u, keepAuthentications)
|
||||||
|
return user, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update failure: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.bus.Publish(app.TopicUserUpdated, *user)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !existingUser.IsDisabled() && user.IsDisabled():
|
||||||
|
m.bus.Publish(app.TopicUserDisabled, *user)
|
||||||
|
case existingUser.IsDisabled() && !user.IsDisabled():
|
||||||
|
m.bus.Publish(app.TopicUserEnabled, *user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) disableMissingLdapUsers(
|
func (m Manager) create(ctx context.Context, user *domain.User) (*domain.User, error) {
|
||||||
ctx context.Context,
|
if user.Identifier == "" {
|
||||||
providerName string,
|
return nil, errors.New("missing user identifier")
|
||||||
rawUsers []internal.RawLdapUser,
|
|
||||||
fields *config.LdapFields,
|
|
||||||
) error {
|
|
||||||
allUsers, err := m.users.GetAllUsers(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
for _, user := range allUsers {
|
|
||||||
if user.Source != domain.UserSourceLdap {
|
|
||||||
continue // ignore non ldap users
|
|
||||||
}
|
|
||||||
if user.ProviderName != providerName {
|
|
||||||
continue // user was synchronized through different provider
|
|
||||||
}
|
|
||||||
if user.IsDisabled() {
|
|
||||||
continue // ignore deactivated
|
|
||||||
}
|
|
||||||
|
|
||||||
existsInLDAP := false
|
existingUser, err := m.users.GetUser(ctx, user.Identifier)
|
||||||
for _, rawUser := range rawUsers {
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
||||||
userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, ""))
|
return nil, fmt.Errorf("unable to load existing user %s: %w", user.Identifier, err)
|
||||||
if user.Identifier == userId {
|
}
|
||||||
existsInLDAP = true
|
if existingUser != nil {
|
||||||
break
|
return nil, errors.Join(fmt.Errorf("user %s already exists", user.Identifier), domain.ErrDuplicateEntry)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if existsInLDAP {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Debug("user is missing in ldap provider, disabling", "user", user.Identifier, "provider", providerName)
|
|
||||||
|
|
||||||
|
// Add default authentication if missing
|
||||||
|
if len(user.Authentications) == 0 {
|
||||||
|
ctxUserInfo := domain.GetUserInfo(ctx)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
user.Disabled = &now
|
user.Authentications = []domain.UserAuthentication{
|
||||||
user.DisabledReason = domain.DisabledReasonLdapMissing
|
{
|
||||||
|
BaseModel: domain.BaseModel{
|
||||||
err := m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
|
CreatedBy: ctxUserInfo.UserId(),
|
||||||
u.Disabled = user.Disabled
|
UpdatedBy: ctxUserInfo.UserId(),
|
||||||
u.DisabledReason = user.DisabledReason
|
CreatedAt: now,
|
||||||
return u, nil
|
UpdatedAt: now,
|
||||||
})
|
},
|
||||||
if err != nil {
|
UserIdentifier: user.Identifier,
|
||||||
return fmt.Errorf("disable error for user id %s: %w", user.Identifier, err)
|
Source: domain.UserSourceDatabase,
|
||||||
|
ProviderName: "",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
m.bus.Publish(app.TopicUserDisabled, user)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
if err := m.validateCreation(ctx, user); err != nil {
|
||||||
|
return nil, fmt.Errorf("creation not allowed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = user.HashPassword()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
|
||||||
|
return user, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creation failure: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.bus.Publish(app.TopicUserCreated, *user)
|
||||||
|
|
||||||
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// endregion internal-modifiers
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/h44z/wg-portal/internal"
|
||||||
"github.com/h44z/wg-portal/internal/domain"
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,11 +14,10 @@ type User struct {
|
|||||||
CreatedAt time.Time `json:"CreatedAt"`
|
CreatedAt time.Time `json:"CreatedAt"`
|
||||||
UpdatedAt time.Time `json:"UpdatedAt"`
|
UpdatedAt time.Time `json:"UpdatedAt"`
|
||||||
|
|
||||||
Identifier string `json:"Identifier"`
|
Identifier string `json:"Identifier"`
|
||||||
Email string `json:"Email"`
|
Email string `json:"Email"`
|
||||||
Source string `json:"Source"`
|
AuthSources []UserAuthSource `json:"AuthSources"`
|
||||||
ProviderName string `json:"ProviderName"`
|
IsAdmin bool `json:"IsAdmin"`
|
||||||
IsAdmin bool `json:"IsAdmin"`
|
|
||||||
|
|
||||||
Firstname string `json:"Firstname,omitempty"`
|
Firstname string `json:"Firstname,omitempty"`
|
||||||
Lastname string `json:"Lastname,omitempty"`
|
Lastname string `json:"Lastname,omitempty"`
|
||||||
@@ -31,8 +31,22 @@ type User struct {
|
|||||||
LockedReason string `json:"LockedReason,omitempty"`
|
LockedReason string `json:"LockedReason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserAuthSource represents a single authentication source for a user.
|
||||||
|
// For details about the fields, see the domain.UserAuthentication struct.
|
||||||
|
type UserAuthSource struct {
|
||||||
|
Source string `json:"Source"`
|
||||||
|
ProviderName string `json:"ProviderName"`
|
||||||
|
}
|
||||||
|
|
||||||
// NewUser creates a new User model from a domain.User
|
// NewUser creates a new User model from a domain.User
|
||||||
func NewUser(src domain.User) User {
|
func NewUser(src domain.User) User {
|
||||||
|
authSources := internal.Map(src.Authentications, func(authentication domain.UserAuthentication) UserAuthSource {
|
||||||
|
return UserAuthSource{
|
||||||
|
Source: string(authentication.Source),
|
||||||
|
ProviderName: authentication.ProviderName,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return User{
|
return User{
|
||||||
CreatedBy: src.CreatedBy,
|
CreatedBy: src.CreatedBy,
|
||||||
UpdatedBy: src.UpdatedBy,
|
UpdatedBy: src.UpdatedBy,
|
||||||
@@ -40,8 +54,7 @@ func NewUser(src domain.User) User {
|
|||||||
UpdatedAt: src.UpdatedAt,
|
UpdatedAt: src.UpdatedAt,
|
||||||
Identifier: string(src.Identifier),
|
Identifier: string(src.Identifier),
|
||||||
Email: src.Email,
|
Email: src.Email,
|
||||||
Source: string(src.Source),
|
AuthSources: authSources,
|
||||||
ProviderName: src.ProviderName,
|
|
||||||
IsAdmin: src.IsAdmin,
|
IsAdmin: src.IsAdmin,
|
||||||
Firstname: src.Firstname,
|
Firstname: src.Firstname,
|
||||||
Lastname: src.Lastname,
|
Lastname: src.Lastname,
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ func (c *ControllerManager) init() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := c.registerPfsenseControllers(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
c.logRegisteredControllers()
|
c.logRegisteredControllers()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -86,6 +90,26 @@ func (c *ControllerManager) registerMikrotikControllers() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *ControllerManager) registerPfsenseControllers() error {
|
||||||
|
for _, backendConfig := range c.cfg.Backend.Pfsense {
|
||||||
|
if backendConfig.Id == config.LocalBackendName {
|
||||||
|
slog.Warn("skipping registration of pfSense controller with reserved ID", "id", config.LocalBackendName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
controller, err := wgcontroller.NewPfsenseController(c.cfg, &backendConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create pfSense controller for backend %s: %w", backendConfig.Id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.controllers[domain.InterfaceBackend(backendConfig.Id)] = backendInstance{
|
||||||
|
Config: backendConfig.BackendBase,
|
||||||
|
Implementation: controller,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ControllerManager) logRegisteredControllers() {
|
func (c *ControllerManager) logRegisteredControllers() {
|
||||||
for backend, controller := range c.controllers {
|
for backend, controller := range c.controllers {
|
||||||
slog.Debug("backend controller registered",
|
slog.Debug("backend controller registered",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/h44z/wg-portal/internal/app"
|
"github.com/h44z/wg-portal/internal/app"
|
||||||
@@ -373,6 +374,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,
|
||||||
DriverType: "",
|
DriverType: "",
|
||||||
Disabled: nil,
|
Disabled: nil,
|
||||||
DisabledReason: "",
|
DisabledReason: "",
|
||||||
@@ -867,6 +869,17 @@ func (m Manager) importInterface(
|
|||||||
iface.Backend = backend.GetId()
|
iface.Backend = backend.GetId()
|
||||||
iface.PeerDefAllowedIPsStr = iface.AddressStr()
|
iface.PeerDefAllowedIPsStr = iface.AddressStr()
|
||||||
|
|
||||||
|
// For pfSense backends, extract endpoint and DNS from peers
|
||||||
|
if backend.GetId() == domain.ControllerTypePfsense {
|
||||||
|
endpoint, dns := extractPfsenseDefaultsFromPeers(peers, iface.ListenPort)
|
||||||
|
if endpoint != "" {
|
||||||
|
iface.PeerDefEndpoint = endpoint
|
||||||
|
}
|
||||||
|
if dns != "" {
|
||||||
|
iface.PeerDefDnsStr = dns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// try to predict the interface type based on the number of peers
|
// try to predict the interface type based on the number of peers
|
||||||
switch len(peers) {
|
switch len(peers) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -904,6 +917,61 @@ func (m Manager) importInterface(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
func extractPfsenseDefaultsFromPeers(peers []domain.PhysicalPeer, listenPort int) (endpoint, dns string) {
|
||||||
|
if len(peers) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count endpoint occurrences to find the most common one
|
||||||
|
endpointCounts := make(map[string]int)
|
||||||
|
dnsValues := make(map[string]int)
|
||||||
|
|
||||||
|
for _, peer := range peers {
|
||||||
|
// Extract endpoint from peer
|
||||||
|
if peer.Endpoint != "" {
|
||||||
|
endpointCounts[peer.Endpoint]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract DNS from peer extras if available
|
||||||
|
if extras := peer.GetExtras(); extras != nil {
|
||||||
|
if pfsenseExtras, ok := extras.(domain.PfsensePeerExtras); ok {
|
||||||
|
if pfsenseExtras.ClientDns != "" {
|
||||||
|
dnsValues[pfsenseExtras.ClientDns]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the most common endpoint
|
||||||
|
maxCount := 0
|
||||||
|
for ep, count := range endpointCounts {
|
||||||
|
if count > maxCount {
|
||||||
|
maxCount = count
|
||||||
|
endpoint = ep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If endpoint doesn't have a port and we have a listenPort, add it
|
||||||
|
if endpoint != "" && listenPort > 0 {
|
||||||
|
if !strings.Contains(endpoint, ":") {
|
||||||
|
endpoint = fmt.Sprintf("%s:%d", endpoint, listenPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the most common DNS
|
||||||
|
maxDnsCount := 0
|
||||||
|
for dnsVal, count := range dnsValues {
|
||||||
|
if count > maxDnsCount {
|
||||||
|
maxDnsCount = count
|
||||||
|
dns = dnsVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpoint, dns
|
||||||
|
}
|
||||||
|
|
||||||
func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain.PhysicalPeer) error {
|
func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain.PhysicalPeer) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
peer := domain.ConvertPhysicalPeer(p)
|
peer := domain.ConvertPhysicalPeer(p)
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti
|
|||||||
continue // only create default peers for server interfaces
|
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 {
|
peerAlreadyCreated := slices.ContainsFunc(userPeers, func(peer domain.Peer) bool {
|
||||||
return peer.InterfaceIdentifier == iface.Identifier
|
return peer.InterfaceIdentifier == iface.Identifier
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -78,7 +78,12 @@ func (f *mockDB) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceId
|
|||||||
func (f *mockDB) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error) {
|
func (f *mockDB) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) { return nil, nil }
|
func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
|
||||||
|
if f.iface != nil {
|
||||||
|
return []domain.Interface{*f.iface}, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
func (f *mockDB) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error) {
|
func (f *mockDB) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -192,3 +197,58 @@ func TestCreatePeer_SetsIdentifier_FromPublicKey(t *testing.T) {
|
|||||||
t.Fatalf("expected peer with identifier %q to be saved in DB", expectedId)
|
t.Fatalf("expected peer with identifier %q to be saved in DB", expectedId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateDefaultPeer_RespectsInterfaceFlag(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Core.CreateDefaultPeer = true
|
||||||
|
|
||||||
|
bus := &mockBus{}
|
||||||
|
ctrlMgr := &ControllerManager{
|
||||||
|
controllers: map[domain.InterfaceBackend]backendInstance{
|
||||||
|
config.LocalBackendName: {Implementation: &mockController{}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
db := &mockDB{
|
||||||
|
iface: &domain.Interface{
|
||||||
|
Identifier: "wg0",
|
||||||
|
Type: domain.InterfaceTypeServer,
|
||||||
|
CreateDefaultPeer: false, // Flag is disabled!
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := Manager{
|
||||||
|
cfg: cfg,
|
||||||
|
bus: bus,
|
||||||
|
db: db,
|
||||||
|
wg: ctrlMgr,
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := domain.UserIdentifier("user@example.com")
|
||||||
|
ctx := domain.SetUserInfo(context.Background(), &domain.ContextUserInfo{Id: userId, IsAdmin: true})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
err := m.CreateDefaultPeer(ctx, userId)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDefaultPeer returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(db.savedPeers) != 0 {
|
||||||
|
t.Fatalf("expected no peers to be created because interface flag is false, but got %d", len(db.savedPeers))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now enable the flag and try again
|
||||||
|
db.iface.CreateDefaultPeer = true
|
||||||
|
err = m.CreateDefaultPeer(ctx, userId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDefaultPeer returned error after enabling flag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(db.savedPeers) != 1 {
|
||||||
|
t.Fatalf("expected 1 peer to be created because interface flag is true, but got %d", len(db.savedPeers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user