Compare commits

..

3 Commits

Author SHA1 Message Date
Christoph Haas
e65cab4857 add funding info, prepare release v2.1 2025-10-04 14:14:08 +02:00
Christoph Haas
75ec234a72 add dark/light image to doc 2025-10-04 13:42:52 +02:00
Christoph Haas
4729bccdd3 add dark/light image to doc 2025-09-17 22:55:17 +02:00
178 changed files with 3740 additions and 15251 deletions

View File

@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@v5
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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - uses: actions/setup-python@v6
with: with:
python-version: '3.x' python-version: '3.x'
- uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0 - uses: helm/chart-testing-action@v2
- 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@8bf8d22160e8b1d184dcb780e390d6952a7eec65 # v1.0.10 - uses: nolar/setup-k3d-k3s@v1
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@v5
- uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 - uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v5
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@v3
- 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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 uses: docker/login-action@v3
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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 uses: docker/login-action@v3
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@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 uses: docker/metadata-action@v5
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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 uses: docker/build-push-action@v6
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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 uses: docker/build-push-action@v6
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 uses: actions/upload-artifact@v4
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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@v5
with: with:
name: binaries name: binaries
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 uses: softprops/action-gh-release@v2
with: with:
files: 'wg-portal_linux*' files: 'wg-portal_linux*'
generate_release_notes: true generate_release_notes: true

View File

@@ -15,11 +15,11 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - uses: actions/setup-python@v6
with: with:
python-version: 3.x python-version: 3.x
@@ -37,4 +37,4 @@ jobs:
run: mike deploy --push --update-aliases ${{ github.ref_name }} latest run: mike deploy --push --update-aliases ${{ github.ref_name }} latest
env: env:
GIT_COMMITTER_NAME: "github-actions[bot]" GIT_COMMITTER_NAME: "github-actions[bot]"
GIT_COMMITTER_EMAIL: "41898282+github-actions[bot]@users.noreply.github.com" GIT_COMMITTER_EMAIL: "41898282+github-actions[bot]@users.noreply.github.com"

View File

@@ -20,7 +20,7 @@ RUN npm run build
###### ######
# Build backend # Build backend
###### ######
FROM --platform=${BUILDPLATFORM} golang:1.26-alpine AS builder FROM --platform=${BUILDPLATFORM} golang:1.25-alpine AS builder
# Set the working directory # Set the working directory
WORKDIR /build WORKDIR /build
# Download dependencies # Download dependencies
@@ -50,9 +50,9 @@ COPY --from=builder /build/dist/wg-portal /
###### ######
# Final image # Final image
###### ######
FROM alpine:3.23 FROM alpine:3.22
# 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
# Setup timezone # Setup timezone
ENV TZ=UTC ENV TZ=UTC
# Copy binaries # Copy binaries

View File

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

View File

@@ -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, MikroTik, or pfSense) * Supports multiple WireGuard backends (wgctrl or MikroTik)
* Peer Expiry Feature * Peer Expiry Feature
* Handles route and DNS settings like wg-quick does * Handles route and DNS settings like wg-quick does
* Exposes Prometheus metrics for monitoring and alerting * Exposes Prometheus metrics for monitoring and alerting

View File

@@ -47,12 +47,14 @@ 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, cfg) database, err := adapters.NewSqlRepository(rawDb)
internal.AssertNoError(err) internal.AssertNoError(err)
wireGuard, err := wireguard.NewControllerManager(cfg) wireGuard, err := wireguard.NewControllerManager(cfg)
internal.AssertNoError(err) internal.AssertNoError(err)
wgQuick := adapters.NewWgQuickRepo()
mailer := adapters.NewSmtpMailRepo(cfg.Mail) mailer := adapters.NewSmtpMailRepo(cfg.Mail)
metricsServer := adapters.NewMetricsServer(cfg) metricsServer := adapters.NewMetricsServer(cfg)
@@ -80,18 +82,18 @@ func main() {
internal.AssertNoError(err) internal.AssertNoError(err)
auditRecorder.StartBackgroundJobs(ctx) auditRecorder.StartBackgroundJobs(ctx)
userManager, err := users.NewUserManager(cfg, eventBus, database, database, database) userManager, err := users.NewUserManager(cfg, eventBus, database, database)
internal.AssertNoError(err) internal.AssertNoError(err)
userManager.StartBackgroundJobs(ctx) userManager.StartBackgroundJobs(ctx)
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, cfg.Web.BasePath, eventBus, userManager) authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager)
internal.AssertNoError(err) internal.AssertNoError(err)
authenticator.StartBackgroundJobs(ctx) authenticator.StartBackgroundJobs(ctx)
webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager) webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager)
internal.AssertNoError(err) internal.AssertNoError(err)
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, database) wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database)
internal.AssertNoError(err) internal.AssertNoError(err)
wireGuardManager.StartBackgroundJobs(ctx) wireGuardManager.StartBackgroundJobs(ctx)
@@ -105,7 +107,7 @@ func main() {
mailManager, err := mail.NewMailManager(cfg, mailer, cfgFileManager, database, database) mailManager, err := mail.NewMailManager(cfg, mailer, cfgFileManager, database, database)
internal.AssertNoError(err) internal.AssertNoError(err)
routeManager, err := route.NewRouteManager(cfg, eventBus, database, wireGuard) routeManager, err := route.NewRouteManager(cfg, eventBus, database)
internal.AssertNoError(err) internal.AssertNoError(err)
routeManager.StartBackgroundJobs(ctx) routeManager.StartBackgroundJobs(ctx)
@@ -135,8 +137,6 @@ func main() {
apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers) apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers)
apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard) apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard)
apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth) apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth)
apiV0EndpointWebsocket := handlersV0.NewWebsocketEndpoint(cfg, apiV0Auth, eventBus, apiV0BackendPeers)
apiV0EndpointWebsocket.StartBackgroundJobs(ctx)
apiFrontend := handlersV0.NewRestApi(apiV0Session, apiFrontend := handlersV0.NewRestApi(apiV0Session,
apiV0EndpointAuth, apiV0EndpointAuth,
@@ -146,7 +146,6 @@ func main() {
apiV0EndpointPeers, apiV0EndpointPeers,
apiV0EndpointConfig, apiV0EndpointConfig,
apiV0EndpointTest, apiV0EndpointTest,
apiV0EndpointWebsocket,
) )
// endregion API v0 (SPA frontend) // endregion API v0 (SPA frontend)

View File

@@ -6,18 +6,12 @@ advanced:
core: core:
admin_user: test@test.de admin_user: test@test.de
admin_password: secret admin_password: secret
create_default_peer_on_login: true create_default_peer: true
create_default_peer_on_user_creation: false create_default_peer_on_creation: false
create_default_peer_on_interface_creation: false
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: ""
@@ -47,10 +41,7 @@ auth:
extra_scopes: extra_scopes:
- https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile - https://www.googleapis.com/auth/userinfo.profile
use_pkce: true
pkce_method: S256
registration_enabled: true registration_enabled: true
logout_idp_session: true
- id: oidc2 - id: oidc2
provider_name: google2 provider_name: google2
display_name: Login with</br>Google2 display_name: Login with</br>Google2
@@ -61,7 +52,6 @@ auth:
- https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile - https://www.googleapis.com/auth/userinfo.profile
registration_enabled: true registration_enabled: true
logout_idp_session: true
oauth: oauth:
- id: google_plain_oauth - id: google_plain_oauth
provider_name: google3 provider_name: google3
@@ -81,7 +71,6 @@ auth:
user_identifier: sub user_identifier: sub
is_admin: this-attribute-must-be-true is_admin: this-attribute-must-be-true
registration_enabled: true registration_enabled: true
use_pkce: false
- id: google_plain_oauth_with_groups - id: google_plain_oauth_with_groups
provider_name: google4 provider_name: google4
display_name: Login with</br>Google4 display_name: Login with</br>Google4
@@ -104,16 +93,4 @@ auth:
admin_value_regex: ^true$ admin_value_regex: ^true$
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

View File

@@ -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-0" kubeVersion: ">=1.19.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.3 version: 0.7.1
# 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

View File

@@ -1,6 +1,6 @@
# wg-portal # wg-portal
![Version: 0.7.3](https://img.shields.io/badge/Version-0.7.3-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v2](https://img.shields.io/badge/AppVersion-v2-informational?style=flat-square) ![Version: 0.7.1](https://img.shields.io/badge/Version-0.7.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v2](https://img.shields.io/badge/AppVersion-v2-informational?style=flat-square)
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-0` Kubernetes: `>=1.19.0`
## Installing the Chart ## Installing the Chart
@@ -41,7 +41,6 @@ The [Values](#values) section lists the parameters that can be configured during
| config.web | tpl/object | `{}` | [Web configuration](https://wgportal.org/latest/documentation/configuration/overview/#web) options.<br> `listening_address` will be set automatically from `service.web.port`. `external_url` is required to enable ingress and certificate resources. | | config.web | tpl/object | `{}` | [Web configuration](https://wgportal.org/latest/documentation/configuration/overview/#web) options.<br> `listening_address` will be set automatically from `service.web.port`. `external_url` is required to enable ingress and certificate resources. |
| revisionHistoryLimit | string | `10` | The number of old ReplicaSets to retain to allow rollback. | | revisionHistoryLimit | string | `10` | The number of old ReplicaSets to retain to allow rollback. |
| workloadType | string | `"Deployment"` | Workload type - `Deployment` or `StatefulSet` | | workloadType | string | `"Deployment"` | Workload type - `Deployment` or `StatefulSet` |
| replicas | int | `1` | The replicas for the workload. |
| strategy | object | `{"type":"RollingUpdate"}` | Update strategy for the workload Valid values are: `RollingUpdate` or `Recreate` for Deployment, `RollingUpdate` or `OnDelete` for StatefulSet | | strategy | object | `{"type":"RollingUpdate"}` | Update strategy for the workload Valid values are: `RollingUpdate` or `Recreate` for Deployment, `RollingUpdate` or `OnDelete` for StatefulSet |
| image.repository | string | `"ghcr.io/h44z/wg-portal"` | Image repository | | image.repository | string | `"ghcr.io/h44z/wg-portal"` | Image repository |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
@@ -75,15 +74,12 @@ The [Values](#values) section lists the parameters that can be configured during
| service.web.type | string | `"ClusterIP"` | Web service type | | service.web.type | string | `"ClusterIP"` | Web service type |
| service.web.port | int | `8888` | Web service port Used for the web interface listener | | service.web.port | int | `8888` | Web service port Used for the web interface listener |
| service.web.appProtocol | string | `"http"` | Web service appProtocol. Will be auto set to `https` if certificate is enabled. | | service.web.appProtocol | string | `"http"` | Web service appProtocol. Will be auto set to `https` if certificate is enabled. |
| service.web.extraSelectorLabels | object | `{}` | Extra labels to append to the selector labels. |
| service.wireguard.annotations | object | `{}` | Annotations for the WireGuard service | | service.wireguard.annotations | object | `{}` | Annotations for the WireGuard service |
| service.wireguard.type | string | `"LoadBalancer"` | Wireguard service type | | service.wireguard.type | string | `"LoadBalancer"` | Wireguard service type |
| service.wireguard.ports | list | `[51820]` | Wireguard service ports. Exposes the WireGuard ports for created interfaces. Lowerest port is selected as start port for the first interface. Increment next port by 1 for each additional interface. | | service.wireguard.ports | list | `[51820]` | Wireguard service ports. Exposes the WireGuard ports for created interfaces. Lowerest port is selected as start port for the first interface. Increment next port by 1 for each additional interface. |
| service.wireguard.extraSelectorLabels | object | `{}` | Extra labels to append to the selector labels. |
| service.metrics.port | int | `8787` | | | service.metrics.port | int | `8787` | |
| ingress.enabled | bool | `false` | Specifies whether an ingress resource should be created | | ingress.enabled | bool | `false` | Specifies whether an ingress resource should be created |
| ingress.className | string | `""` | Ingress class name | | ingress.className | string | `""` | Ingress class name |
| ingress.pathType | string | `"ImplementationSpecific"` | Ingress pathType value. Valid values are `ImplementationSpecific`, `Exact` or `Prefix`. |
| ingress.annotations | object | `{}` | Ingress annotations | | ingress.annotations | object | `{}` | Ingress annotations |
| ingress.tls | bool | `false` | Ingress TLS configuration. Enable certificate resource or add ingress annotation to create required secret | | ingress.tls | bool | `false` | Ingress TLS configuration. Enable certificate resource or add ingress annotation to create required secret |
| certificate.enabled | bool | `false` | Specifies whether a certificate resource should be created. If enabled, certificate will be used for the web. | | certificate.enabled | bool | `false` | Specifies whether a certificate resource should be created. If enabled, certificate will be used for the web. |

View File

@@ -49,7 +49,7 @@ spec:
{{- with .scope.type }} {{- with .scope.type }}
type: {{ . }} type: {{ . }}
{{- end }} {{- end }}
selector: {{- include "wg-portal.util.merge" (list .context .scope.extraSelectorLabels "wg-portal.selectorLabels") | nindent 4 }} selector: {{- include "wg-portal.selectorLabels" .context | nindent 4 }}
{{- end -}} {{- end -}}
{{/* {{/*

View File

@@ -8,9 +8,6 @@ spec:
{{- with .Values.revisionHistoryLimit }} {{- with .Values.revisionHistoryLimit }}
revisionHistoryLimit: {{ . }} revisionHistoryLimit: {{ . }}
{{- end }} {{- end }}
{{- with .Values.replicas }}
replicas: {{ . }}
{{- end }}
{{- with .Values.strategy }} {{- with .Values.strategy }}
strategy: {{- toYaml . | nindent 4 }} strategy: {{- toYaml . | nindent 4 }}
{{- end }} {{- end }}

View File

@@ -15,7 +15,7 @@ spec:
http: http:
paths: paths:
- path: {{ default "/" (urlParse (tpl .Values.config.web.external_url .)).path }} - path: {{ default "/" (urlParse (tpl .Values.config.web.external_url .)).path }}
pathType: {{ default "ImplementationSpecific" .Values.ingress.pathType }} pathType: {{ default "ImplementationSpecific" .pathType }}
backend: backend:
service: service:
name: {{ include "wg-portal.fullname" . }} name: {{ include "wg-portal.fullname" . }}

View File

@@ -8,9 +8,6 @@ spec:
{{- with .Values.revisionHistoryLimit }} {{- with .Values.revisionHistoryLimit }}
revisionHistoryLimit: {{ . }} revisionHistoryLimit: {{ . }}
{{- end }} {{- end }}
{{- with .Values.replicas }}
replicas: {{ . }}
{{- end }}
{{- with .Values.strategy }} {{- with .Values.strategy }}
updateStrategy: {{- toYaml . | nindent 4 }} updateStrategy: {{- toYaml . | nindent 4 }}
{{- end }} {{- end }}

View File

@@ -35,9 +35,6 @@ config:
revisionHistoryLimit: "" revisionHistoryLimit: ""
# -- Workload type - `Deployment` or `StatefulSet` # -- Workload type - `Deployment` or `StatefulSet`
workloadType: Deployment workloadType: Deployment
# -- The replicas for the workload.
# @default -- `1`
replicas: 1
# -- Update strategy for the workload # -- Update strategy for the workload
# Valid values are: # Valid values are:
# `RollingUpdate` or `Recreate` for Deployment, # `RollingUpdate` or `Recreate` for Deployment,
@@ -127,8 +124,6 @@ service:
port: 8888 port: 8888
# -- Web service appProtocol. Will be auto set to `https` if certificate is enabled. # -- Web service appProtocol. Will be auto set to `https` if certificate is enabled.
appProtocol: http appProtocol: http
# -- Extra labels to append to the selector labels.
extraSelectorLabels: {}
wireguard: wireguard:
# -- Annotations for the WireGuard service # -- Annotations for the WireGuard service
annotations: {} annotations: {}
@@ -140,8 +135,6 @@ service:
# Increment next port by 1 for each additional interface. # Increment next port by 1 for each additional interface.
ports: ports:
- 51820 - 51820
# -- Extra labels to append to the selector labels.
extraSelectorLabels: {}
metrics: metrics:
port: 8787 port: 8787
@@ -150,10 +143,6 @@ ingress:
enabled: false enabled: false
# -- Ingress class name # -- Ingress class name
className: "" className: ""
# -- Ingress pathType value.
# Valid values are `ImplementationSpecific`, `Exact` or `Prefix`.
# @default -- `"ImplementationSpecific"`
pathType: "ImplementationSpecific"
# -- Ingress annotations # -- Ingress annotations
annotations: {} annotations: {}
# -- Ingress TLS configuration. # -- Ingress TLS configuration.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View File

@@ -8,16 +8,13 @@ core:
admin_password: password admin_password: password
admin_api_token: super-s3cr3t-api-token-or-a-UUID admin_api_token: super-s3cr3t-api-token-or-a-UUID
import_existing: false import_existing: false
create_default_peer_on_login: true create_default_peer: true
self_provisioning_allowed: true self_provisioning_allowed: true
backend: backend:
# default backend decides where new interfaces are created # default backend decides where new interfaces are created
default: mikrotik default: mikrotik
# A prefix for resolvconf. Usually it is "tun.". If you are using systemd, the prefix should be empty.
local_resolvconf_prefix: "tun."
mikrotik: mikrotik:
- id: mikrotik # unique id, not "local" - id: mikrotik # unique id, not "local"
display_name: RouterOS RB5009 # optional nice name display_name: RouterOS RB5009 # optional nice name
@@ -67,7 +64,8 @@ auth:
auth: auth:
ldap: ldap:
# a sample LDAP provider with user sync enabled # a sample LDAP provider with user sync enabled
- provider_name: ldap - id: 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
@@ -86,9 +84,6 @@ auth:
memberof: memberOf memberof: memberOf
admin_group: CN=WireGuardAdmins,OU=Some-OU,DC=COMPANY,DC=LOCAL admin_group: CN=WireGuardAdmins,OU=Some-OU,DC=COMPANY,DC=LOCAL
registration_enabled: true registration_enabled: true
# Restrict interface access based on LDAP filters
interface_filter:
wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)"
log_user_info: true log_user_info: true
``` ```
@@ -101,7 +96,8 @@ 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.
- provider_name: azure - id: 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"
@@ -114,7 +110,8 @@ 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
- provider_name: google - id: oidc-with-admin-attribute
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,7 +133,8 @@ 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
- provider_name: google2 - id: oidc-with-admin-group
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
@@ -144,9 +142,6 @@ auth:
extra_scopes: extra_scopes:
- https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile - https://www.googleapis.com/auth/userinfo.profile
allowed_user_groups:
- the-admin-group
- vpn-users
field_map: field_map:
user_identifier: sub user_identifier: sub
email: email email: email
@@ -170,7 +165,8 @@ 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
- provider_name: google3 - id: google_plain_oauth-with-admin-attribute
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
@@ -192,7 +188,8 @@ 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
- provider_name: google4 - id: google_plain_oauth_with_groups
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
@@ -204,9 +201,6 @@ auth:
- email - email
- profile - profile
- i-want-some-groups - i-want-some-groups
allowed_user_groups:
- admin-group-name
- vpn-users
field_map: field_map:
email: email email: email
firstname: name firstname: name

View File

@@ -28,8 +28,6 @@ core:
backend: backend:
default: local default: local
rekey_timeout_interval: 125s
local_resolvconf_prefix: tun.
advanced: advanced:
log_level: info log_level: info
@@ -74,8 +72,6 @@ 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: []
@@ -89,7 +85,6 @@ 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
@@ -99,7 +94,6 @@ web:
expose_host_info: false expose_host_info: false
cert_file: "" cert_file: ""
key_File: "" key_File: ""
frontend_filepath: ""
webhook: webhook:
url: "" url: ""
@@ -131,81 +125,51 @@ 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` (deprecated) ### `create_default_peer`
- **Default:** `false` - **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER` - **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for **all** server interfaces.
- **Description:** **DEPRECATED** in favor of [create_default_peer_on_login](#create_default_peer_on_login). If set to `true`, this option is equivalent to enabling `create_default_peer_on_login`. It will be removed in a future release (2.4).
### `create_default_peer_on_creation` (deprecated) ### `create_default_peer_on_creation`
- **Default:** `false` - **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION` - **Description:** If an LDAP user is created (e.g., through LDAP sync) and has no peers, automatically create a new WireGuard peer for **all** server interfaces.
- **Description:** **DEPRECATED** in favor of [create_default_peer_on_user_creation](#create_default_peer_on_user_creation) and [create_default_peer_on_interface_creation](#create_default_peer_on_interface_creation). If set to `true`, both of those options are enabled. It will be removed in a future release (2.4).
### `create_default_peer_on_login`
- **Default:** `false`
- **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_user_creation`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_USER_CREATION`
- **Description:** If a new user is created (e.g., through LDAP sync or registration) and has no peers, automatically create a new WireGuard peer for all server interfaces where the "Create default peer" flag is set.
- **Important:** This option is only effective for interfaces where the "Create default peer" flag is set (via the UI).
### `create_default_peer_on_interface_creation`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_INTERFACE_CREATION`
- **Description:** When a new server interface is created with the "Create default peer" flag set, automatically create a default WireGuard peer on that interface for every existing user who does not yet have a peer on it.
- **Important:** This option is only effective for interfaces where the "Create default peer" flag is set (via the UI).
### `re_enable_peer_after_user_enable` ### `re_enable_peer_after_user_enable`
- **Default:** `true` - **Default:** `true`
- **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.
--- ---
@@ -220,23 +184,8 @@ The current MikroTik backend is in **BETA** and may not support all features.
- **Description:** The default backend to use for managing WireGuard interfaces. - **Description:** The default backend to use for managing WireGuard interfaces.
Valid options are: `local`, or other backend id's configured in the `mikrotik` section. Valid options are: `local`, or other backend id's configured in the `mikrotik` section.
### `rekey_timeout_interval`
- **Default:** `180s`
- **Environment Variable:** `WG_PORTAL_BACKEND_REKEY_TIMEOUT_INTERVAL`
- **Description:** The interval after which a WireGuard peer is considered disconnected if no handshake updates are received.
This corresponds to the WireGuard rekey timeout setting of 120 seconds plus a 60-second buffer to account for latency or retry handling.
Uses Go duration format (e.g., `10s`, `1m`). If omitted, a default of 180 seconds is used.
### `local_resolvconf_prefix`
- **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*.
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.
@@ -300,67 +249,54 @@ 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.
--- ---
@@ -374,22 +310,18 @@ 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
@@ -398,7 +330,6 @@ 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 its next modified. New or updated records will be encrypted; existing data remains in plaintext until its next modified.
@@ -411,114 +342,82 @@ 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`).
--- ---
## 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
@@ -530,14 +429,12 @@ 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).
@@ -552,7 +449,6 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
#### `provider_name` #### `provider_name`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A **unique** name for this provider. Must not conflict with other providers. - **Description:** A **unique** name for this provider. Must not conflict with other providers.
This name is used to derive the callback URL for the OIDC provider: `<external_url>/api/v0/auth/login/<provider_name>/callback`.
#### `display_name` #### `display_name`
- **Default:** *(empty)* - **Default:** *(empty)*
@@ -578,10 +474,6 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups. - **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups.
#### `allowed_user_groups`
- **Default:** *(empty)*
- **Description:** A list of allowlisted user groups. If configured, at least one entry in the mapped `user_groups` claim must match one of these values.
#### `field_map` #### `field_map`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** Maps OIDC claims to WireGuard Portal user fields. - **Description:** Maps OIDC claims to WireGuard Portal user fields.
@@ -603,33 +495,15 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- **Description:** WgPortal can grant a user admin rights by matching the value of the `is_admin` claim against a regular expression. Alternatively, a regular expression can be used to check if a user is member of a specific group listed in the `user_group` claim. The regular expressions are defined in `admin_value_regex` and `admin_group_regex`. - **Description:** WgPortal can grant a user admin rights by matching the value of the `is_admin` claim against a regular expression. Alternatively, a regular expression can be used to check if a user is member of a specific group listed in the `user_group` claim. The regular expressions are defined in `admin_value_regex` and `admin_group_regex`.
- `admin_value_regex`: A regular expression to match the `is_admin` claim. By default, this expression matches the string "true" (`^true$`). - `admin_value_regex`: A regular expression to match the `is_admin` claim. By default, this expression matches the string "true" (`^true$`).
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex. - `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
- To identify which claim to match against, set log_level: debug and reload the config. Log in with the intended admin account and inspect the logs for the OIDC user info payload. If the required claim is missing it must be added by the OIDC provider. If it is present, use its value as the pattern for admin_group_regex.
#### `registration_enabled` #### `registration_enabled`
- **Default:** `false` - **Default:** *(empty)*
- **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:** `false` - **Default:** *(empty)*
- **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.
#### `use_pkce`
- **Default:** `true`
- **Description:** If `true`, Proof Key for Code Exchange (PKCE) is used for the OIDC authorization code flow. A fresh `code_verifier` is generated per login request, the matching `code_challenge` is sent with the authorization request, and the `code_verifier` is included in the token exchange. Set to `false` only for providers that do not support PKCE.
#### `pkce_method`
- **Default:** `S256`
- **Description:** PKCE challenge method to use when `use_pkce` is enabled. Supported values are `S256` and `plain`. `S256` is recommended; use `plain` only for providers that explicitly require it.
#### `logout_idp_session`
- **Default:** `true`
- **Description:** If `true` (default), WireGuard Portal will redirect the user to the OIDC provider's `end_session_endpoint` after local logout, terminating the session at the IdP as well. Set to `false` to only invalidate the local WireGuard Portal session without touching the IdP session.
--- ---
### OAuth ### OAuth
@@ -640,7 +514,6 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
#### `provider_name` #### `provider_name`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A **unique** name for this provider. Must not conflict with other providers. - **Description:** A **unique** name for this provider. Must not conflict with other providers.
This name is used to derive the callback URL for the OAuth provider: `<external_url>/api/v0/auth/login/<provider_name>/callback`.
#### `display_name` #### `display_name`
- **Default:** *(empty)* - **Default:** *(empty)*
@@ -674,10 +547,6 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups. - **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups.
#### `allowed_user_groups`
- **Default:** *(empty)*
- **Description:** A list of allowlisted user groups. If configured, at least one entry in the mapped `user_groups` claim must match one of these values.
#### `field_map` #### `field_map`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** Maps OAuth attributes to WireGuard Portal fields. - **Description:** Maps OAuth attributes to WireGuard Portal fields.
@@ -701,26 +570,13 @@ 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:** `false` - **Default:** *(empty)*
- **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:** `false` - **Default:** *(empty)*
- **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.
#### `use_pkce`
- **Default:** `true`
- **Description:** If `true`, Proof Key for Code Exchange (PKCE) is used for the OIDC authorization code flow. A fresh `code_verifier` is generated per login request, the matching `code_challenge` is sent with the authorization request, and the `code_verifier` is included in the token exchange. Set to `false` only for providers that do not support PKCE.
#### `pkce_method`
- **Default:** `S256`
- **Description:** PKCE challenge method to use when `use_pkce` is enabled. Supported values are `S256` and `plain`. `S256` is recommended; use `plain` only for providers that explicitly require it.
--- ---
### LDAP ### LDAP
@@ -737,11 +593,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:** `false` - **Default:** *(empty)*
- **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:** `false` - **Default:** *(empty)*
- **Description:** If `true`, validate the LDAP servers TLS certificate. - **Description:** If `true`, validate the LDAP servers TLS certificate.
#### `tls_certificate_path` #### `tls_certificate_path`
@@ -789,16 +645,6 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
- **Important**: The `login_filter` must always be a valid LDAP filter. It should at most return one user. - **Important**: The `login_filter` must always be a valid LDAP filter. It should at most return one user.
If the filter returns multiple or no users, the login will fail. If the filter returns multiple or no users, the login will fail.
#### `interface_filter`
- **Default:** *(empty)*
- **Description:** A map of LDAP filters to restrict access to specific WireGuard interfaces. The map keys are the interface identifiers (e.g., `wg0`), and the values are LDAP filters. Only users matching the filter will be allowed to provision peers for the respective interface.
For example:
```yaml
interface_filter:
wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)"
wg1: "(description=special-access)"
```
#### `admin_group` #### `admin_group`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** A specific LDAP group whose members are considered administrators in WireGuard Portal. - **Description:** A specific LDAP group whose members are considered administrators in WireGuard Portal.
@@ -820,24 +666,20 @@ 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:** `false` - **Default:** *(empty)*
- **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:** `false` - **Default:** *(empty)*
- **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:** `false` - **Default:** *(empty)*
- **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:** `false` - **Default:** *(empty)*
- **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.
--- ---
@@ -848,7 +690,6 @@ 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.
@@ -861,76 +702,50 @@ 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
@@ -940,15 +755,12 @@ 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.

View File

@@ -9,11 +9,6 @@ Make sure that you download the correct binary for your architecture. The availa
- `wg-portal_linux_arm64` - Linux ARM 64-bit - `wg-portal_linux_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
@@ -32,92 +27,16 @@ 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/
``` ```
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/). ## Unreleased versions (master branch builds)
Refer to [Systemd Service Setup](#systemd-service-setup) for instructions.
## Systemd Integration Unreleased versions can be fetched directly from the artifacts section of the [GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster).
> **Note:** To run WireGuard Portal as systemd service, you need to download the binary for your architecture beforehand.
>
> The following examples assume that you downloaded the binary to `/opt/wg-portal/wg-portal`.
> The configuration file is expected to be located at `/opt/wg-portal/config.yml`.
### Limit Systemd-Networkd Management Scope
If you are using `systemd-networkd` to manage the rest of your network
configuration, you will need to ensure it doesn't remove routing policy
created by `wg-portal` when it restarts:
```shell
sudo mkdir --parents /etc/systemd/networkd.conf.d/
sudo tee --append /etc/systemd/networkd.conf.d/foreign-routing.conf <<EOF
[Network]
ManageForeignRoutingPolicyRules=no
EOF
sudo systemctl restart systemd-networkd.service
sudo systemctl status systemd-networkd.service
```
### Wireguard Portal Service Setup
To run WireGuard Portal as a systemd service, you can create a service unit file. The easiest way to do this is by using `systemctl edit`:
```shell
sudo systemctl edit --force --full wg-portal.service
```
Paste the following content into the editor and adjust the variables to your needs:
```ini
[Unit]
Description=WireGuard Portal
ConditionPathExists=/opt/wg-portal/wg-portal
After=network.target
[Service]
Type=simple
User=root
Group=root
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
Restart=on-failure
RestartSec=10
WorkingDirectory=/opt/wg-portal
Environment=WG_PORTAL_CONFIG=/opt/wg-portal/config.yml
ExecStart=/opt/wg-portal/wg-portal
[Install]
WantedBy=multi-user.target
```
Alternatively, you can create or modify the file manually in `/etc/systemd/system/wg-portal.service`.
For systemd to pick up the changes, you need to reload the daemon:
```shell
sudo systemctl daemon-reload
```
After creating the service file, you can enable and start the service:
```shell
sudo systemctl enable --now wg-portal.service
```
To check status and log output, use: `sudo systemctl status wg-portal.service` or `sudo journalctl -u wg-portal.service`.

View File

@@ -35,14 +35,6 @@ WireGuard Portal supports managing WireGuard interfaces through three distinct d
> :warning: If host networking is used, the WireGuard Portal UI will be accessible on all the host's IP addresses if the listening address is set to `:8888` in the configuration file. > :warning: If host networking is used, the WireGuard Portal UI will be accessible on all the host's IP addresses if the listening address is set to `:8888` in the configuration file.
To avoid this, you can bind the listening address to a specific IP address, for example, the loopback address (`127.0.0.1:8888`). It is also possible to deploy firewall rules to restrict access to the WireGuard Portal UI. To avoid this, you can bind the listening address to a specific IP address, for example, the loopback address (`127.0.0.1:8888`). It is also possible to deploy firewall rules to restrict access to the WireGuard Portal UI.
> :warning: If the host is running **systemd-networkd**, routes managed by WireGuard Portal may be removed whenever systemd-networkd restarts, as it will clean up routes it considers "foreign". To prevent this, add the following to your host's network configuration (e.g. `/etc/systemd/networkd.conf` or a drop-in file):
> ```ini
> [Network]
> ManageForeignRoutingPolicyRules=no
> ManageForeignRoutes=no
> ```
> After editing, reload the configuration with `sudo systemctl restart systemd-networkd`. For more information refer to the [systemd-networkd documentation](https://www.freedesktop.org/software/systemd/man/latest/networkd.conf.html#ManageForeignRoutes=).
- **Within the WireGuard Portal Docker container**: - **Within the WireGuard Portal Docker container**:
WireGuard interfaces can be managed directly from within the WireGuard Portal container itself. WireGuard interfaces can be managed directly from within the WireGuard Portal container itself.
This is the recommended approach when running WireGuard Portal via Docker, as it encapsulates all functionality in a single, portable container without requiring a separate WireGuard host or image. This is the recommended approach when running WireGuard Portal via Docker, as it encapsulates all functionality in a single, portable container without requiring a separate WireGuard host or image.

View File

@@ -84,16 +84,6 @@ 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.

View File

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

View File

@@ -443,18 +443,6 @@ 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
@@ -515,6 +503,17 @@ 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

View File

@@ -1,215 +0,0 @@
WireGuard Portal supports multiple authentication mechanisms to manage user access. This includes
- Local user accounts
- LDAP authentication
- OAuth2 and OIDC authentication
- Passkey authentication (WebAuthn)
Users can have two roles which limit their permissions in WireGuard Portal:
- **User**: Can manage their own account and peers.
- **Admin**: Can manage all users and peers, including the ability to manage WireGuard interfaces.
In general, each user is identified by a _unique identifier_. If the same user identifier exists across multiple authentication sources, WireGuard Portal automatically merges those accounts into a single user record.
When a user is associated with multiple authentication sources, their information in WireGuard Portal is updated based on the most recently logged-in source. For more details, see [User Synchronization](./user-sync.md) documentation.
## Password Authentication
WireGuard Portal supports username and password authentication for both local and LDAP-backed accounts.
Local users are stored in the database, while LDAP users are authenticated against an external LDAP server.
On initial startup, WireGuard Portal automatically creates a local admin account with the password `wgportal-default`.
> :warning: This password must be changed immediately after the first login.
The minimum password length for all local users can be configured in the [`auth`](../configuration/overview.md#auth)
section of the configuration file. The default value is **16** characters, see [`min_password_length`](../configuration/overview.md#min_password_length).
The minimum password length is also enforced for the default admin user.
## Passkey (WebAuthn) Authentication
Besides the standard authentication mechanisms, WireGuard Portal supports Passkey authentication.
This feature is enabled by default and can be configured in the [`webauthn`](../configuration/overview.md#webauthn-passkeys) section of the configuration file.
Users can register multiple Passkeys to their account. These Passkeys can be used to log in to the web UI as long as the user is not locked.
> :warning: Passkey authentication does not disable password authentication. The password can still be used to log in (e.g., as a fallback).
To register a Passkey, open the settings page *(1)* in the web UI and click on the "Register Passkey" *(2)* button.
![Passkey UI](../../assets/images/passkey_setup.png)
## OAuth2 and OIDC Authentication
WireGuard Portal supports OAuth2 and OIDC authentication. You can use any OAuth2 or OIDC provider that supports the authorization code flow,
such as Google, GitHub, or Keycloak.
For OAuth2 or OIDC to work, you need to configure the [`external_url`](../configuration/overview.md#external_url) property in the [`web`](../configuration/overview.md#web) section of the configuration file.
If you are planning to expose the portal to the internet, make sure that the `external_url` is configured to use HTTPS.
To add OIDC or OAuth2 authentication to WireGuard Portal, create a Client-ID and Client-Secret in your OAuth2 provider and
configure a new authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
Make sure that each configured provider has a unique `provider_name` property set. Samples can be seen [here](../configuration/examples.md).
When registering the OAuth2 or OIDC application with your provider, configure the callback/redirect URL as follows:
```text
<external_url>/api/v0/auth/login/<provider_name>/callback
```
Replace `<external_url>` with the value configured in [`external_url`](../configuration/overview.md#external_url) and
`<provider_name>` with the exact `provider_name` from the matching OAuth2 or OIDC provider configuration.
#### Limiting Login to Specific Domains
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"
```
#### Limiting Login to Specific User Groups
You can limit the login to specific user groups by setting the `allowed_user_groups` property for OAuth2 or OIDC providers.
If this property is not empty, the user's `user_groups` claim must contain at least one matching group.
To use this feature, ensure your group claim is mapped via `field_map.user_groups`.
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
allowed_user_groups:
- "wg-users"
- "wg-admins"
field_map:
user_groups: "groups"
```
If `allowed_user_groups` is configured and the authenticated user has no matching group in `user_groups`, login is denied.
Minimal deny-by-group example:
```yaml
auth:
oauth:
- provider_name: "oauth1"
# ... other settings
allowed_user_groups:
- "vpn-users"
field_map:
user_groups: "groups"
```
#### Limit Login to Existing Users
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.
### Interface-specific Provisioning Filters
You can restrict which users are allowed to provision peers for specific WireGuard interfaces by setting the `interface_filter` property.
This property is a map where each key corresponds to a WireGuard interface identifier, and the value is an LDAP filter.
A user will only be able to see and provision peers for an interface if they match the specified LDAP filter for that interface.
Example:
```yaml
auth:
ldap:
- provider_name: "ldap1"
# ... other settings
interface_filter:
wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)"
wg1: "(department=IT)"
```
This feature works by materializing the list of authorized users for each interface during the periodic LDAP synchronization.
Even if a user bypasses the UI, the backend will enforce these restrictions at the service layer.
## User Synchronization

View File

@@ -8,7 +8,6 @@ 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,37 +54,4 @@ 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. wg-portal is developed for and tested against REST API v2.8.0.
### 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.

View File

@@ -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) or [Authentication](authentication.md) sections for more information on securing the web UI. By default, WireGuard Portal listens on port `8888` for HTTP connections. Check the [Security](security.md) section for more information on securing the web UI.
So the default URL to access the web UI is: So the default URL to access the web UI is:

View File

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

View File

@@ -1,49 +0,0 @@
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.

View File

@@ -1,29 +1,153 @@
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.
## Database Encryption ## Authentication
WireGuard Portal supports multiple database backends. To reduce the risk of data exposure, sensitive information stored in the database can be encrypted. WireGuard Portal supports multiple authentication methods, including:
To enable encryption, set the [`encryption_passphrase`](../configuration/overview.md#database) in the database configuration section.
> :warning: Important: Once encryption is enabled, it cannot be disabled, and the passphrase cannot be changed! - Local user accounts
> Only new or updated records will be encrypted; existing data remains in plaintext until its next modified. - LDAP authentication
- OAuth and OIDC authentication
- Passkey authentication (WebAuthn)
## External Identity Provider Data Sanitization Users can have two roles which limit their permissions in WireGuard Portal:
When users authenticate via LDAP, OIDC, or OAuth, WireGuard Portal sanitizes the field values received from the provider before storing them. This protects against several classes of attack that a compromised or misconfigured identity provider could introduce: - **User**: Can manage their own account and peers.
- **Admin**: Can manage all users and peers, including the ability to manage WireGuard interfaces.
- **Unsafe control characters** — Unicode control and format characters, null bytes, and invalid UTF-8 bytes are stripped from external profile fields before they reach the Vue.js UI or email templates. ### Password Security
- **Email header injection** — carriage return and line feed characters in email fields are rejected entirely, and email fields must parse as plain email addresses.
- **Log injection** — unsafe control and format characters are stripped from all external profile fields and from sanitization log context.
- **Denial of service via oversized fields** — field lengths are capped (e.g., 256 runes for identifiers, 254 characters for email addresses).
- **Reserved identifier collision** — reserved user identifiers such as `"all"`, `"new"`, `"id"`, and internal system user identifiers are rejected.
- **Unsafe authorization groups** — OIDC/OAuth group claims are sanitized before group-based checks; groups changed by control/format stripping or truncation are dropped rather than repaired into allowed/admin matches.
Sanitization is always enabled and cannot be disabled. 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.
When sanitization modifies or clears a field value, a `WARN` log entry is emitted with the provider name, provider type, and field name — but never the raw or sanitized value, to avoid leaking sensitive data into logs. This makes it straightforward to detect and investigate potentially malicious or misconfigured providers. On initial startup, WireGuard Portal automatically creates a local admin account with the password `wgportal-default`.
> :warning: This password must be changed immediately after the first login.
The minimum password length for all local users can be configured in the [`auth`](../configuration/overview.md#auth)
section of the configuration file. The default value is **16** characters, see [`min_password_length`](../configuration/overview.md#min_password_length).
The minimum password length is also enforced for the default admin user.
### Passkey (WebAuthn) Authentication
Besides the standard authentication mechanisms, WireGuard Portal supports Passkey authentication.
This feature is enabled by default and can be configured in the [`webauthn`](../configuration/overview.md#webauthn-passkeys) section of the configuration file.
Users can register multiple Passkeys to their account. These Passkeys can be used to log in to the web UI as long as the user is not locked.
> :warning: Passkey authentication does not disable password authentication. The password can still be used to log in (e.g., as a fallback).
To register a Passkey, open the settings page *(1)* in the web UI and click on the "Register Passkey" *(2)* button.
![Passkey UI](../../assets/images/passkey_setup.png)
### OAuth and OIDC Authentication
WireGuard Portal supports OAuth and OIDC authentication. You can use any OAuth or OIDC provider that supports the authorization code flow,
such as Google, GitHub, or Keycloak.
For OAuth or OIDC to work, you need to configure the [`external_url`](../configuration/overview.md#external_url) property in the [`web`](../configuration/overview.md#web) section of the configuration file.
If you are planning to expose the portal to the internet, make sure that the `external_url` is configured to use HTTPS.
To add OIDC or OAuth authentication to WireGuard Portal, create a Client-ID and Client-Secret in your OAuth provider and
configure a new authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
Make sure that each configured provider has a unique `provider_name` property set. Samples can be seen [here](../configuration/examples.md).
#### Limiting Login to Specific Domains
You can limit the login to specific domains by setting the `allowed_domains` property for OAuth or OIDC providers.
This property is a comma-separated list of domains that are allowed to log in. The user's email address is checked against this list.
For example, if you want to allow only users with an email address ending in `outlook.com` to log in, set the property as follows:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
allowed_domains:
- "outlook.com"
```
#### Limit Login to Existing Users
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for OAuth or OIDC providers.
If registration is enabled, new users will be created in the database when they log in for the first time.
#### Admin Mapping
You can map users to admin roles based on their attributes in the OAuth or OIDC provider. To do this, set the `admin_mapping` property for the provider.
Administrative access can either be mapped by a specific attribute or by group membership.
**Attribute specific mapping** can be achieved by setting the `admin_value_regex` and the `is_admin` property.
The `admin_value_regex` property is a regular expression that is matched against the value of the `is_admin` attribute.
The user is granted admin access if the regex matches the attribute value.
Example:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
field_map:
is_admin: "wg_admin_prop"
admin_mapping:
admin_value_regex: "^true$"
```
The example above will grant admin access to users with the `wg_admin_prop` attribute set to `true`.
**Group membership mapping** can be achieved by setting the `admin_group_regex` and `user_groups` property.
The `admin_group_regex` property is a regular expression that is matched against the group names of the user.
The user is granted admin access if the regex matches any of the group names.
Example:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
field_map:
user_groups: "groups"
admin_mapping:
admin_group_regex: "^the-admin-group$"
```
The example above will grant admin access to users who are members of the `the-admin-group` group.
### LDAP Authentication
WireGuard Portal supports LDAP authentication. You can use any LDAP server that supports the LDAP protocol, such as Active Directory or OpenLDAP.
Multiple LDAP servers can be configured in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
WireGuard Portal remembers the authentication provider of the user and therefore avoids conflicts between multiple LDAP providers.
To configure LDAP authentication, create a new [`ldap`](../configuration/overview.md#ldap) authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
#### Limiting Login to Specific Users
You can limit the login to specific users by setting the `login_filter` property for LDAP provider. This filter uses the LDAP search filter syntax.
The username can be inserted into the query by placing the `{{login_identifier}}` placeholder in the filter. This placeholder will then be replaced with the username entered by the user during login.
For example, if you want to allow only users with the `objectClass` attribute set to `organizationalPerson` to log in, set the property as follows:
```yaml
auth:
ldap:
- provider_name: "ldap1"
# ... other settings
login_filter: "(&(objectClass=organizationalPerson)(uid={{login_identifier}}))"
```
The `login_filter` should always be designed to return at most one user.
#### Limit Login to Existing Users
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for LDAP providers.
If registration is enabled, new users will be created in the database when they log in for the first time.
#### Admin Mapping
You can map users to admin roles based on their group membership in the LDAP server. To do this, set the `admin_group` and `memberof` property for the provider.
The `admin_group` property defines the distinguished name of the group that is allowed to log in as admin.
All groups that are listed in the `memberof` attribute of the user will be checked against this group. If one of the groups matches, the user is granted admin access.
---
## UI and API Access ## UI and API Access
@@ -33,9 +157,4 @@ WireGuard Portal provides a web UI and a REST API for user interaction. It is im
It is recommended to use HTTPS for all communication with the portal to prevent eavesdropping. It is recommended to use HTTPS for all communication with the portal to prevent eavesdropping.
Event though, WireGuard Portal supports HTTPS out of the box, it is recommended to use a reverse proxy like Nginx or Traefik to handle SSL termination and other security features. 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.

View File

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

View File

@@ -68,32 +68,26 @@ 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 |
| AuthSources | []AuthSource | Authentication sources | | Source | string | Authentication source |
| IsAdmin | bool | Whether user has admin privileges | | ProviderName | string | Name of auth provider |
| Firstname | string | User's first name (optional) | | IsAdmin | bool | Whether user has admin privileges |
| Lastname | string | User's last name (optional) | | Firstname | string | User's first name (optional) |
| Phone | string | Contact phone number (optional) | | Lastname | string | User's last name (optional) |
| Department | string | User's department (optional) | | Phone | string | Contact phone number (optional) |
| Notes | string | Additional notes (optional) | | Department | string | User's department (optional) |
| Disabled | *time.Time | When user was disabled | | Notes | string | Additional notes (optional) |
| DisabledReason | string | Reason for deactivation | | Disabled | *time.Time | When user was disabled |
| Locked | *time.Time | When user account was locked | | DisabledReason | string | Reason for deactivation |
| LockedReason | string | Reason for being locked | | Locked | *time.Time | When user account was locked |
| LockedReason | string | Reason for being locked |
`AuthSource`:
| JSON Field | Type | Description |
|--------------|---------------|-----------------------------------------------------|
| Source | string | The authentication source (e.g. LDAP, OAuth, or DB) |
| ProviderName | string | The identifier of the authentication provider |
#### Peer Payload (entity: `peer`) #### Peer Payload (entity: `peer`)

View File

@@ -68,7 +68,7 @@
} }
.tx-hero__image { .tx-hero__image {
max-width: 1000px; max-width: 1000px;
min-width: 0; min-width: 600px;
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; background-color: #fff none repeat scroll 0% 0%;
padding: 1.5rem; padding: 1.5rem;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -363,6 +363,7 @@
<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"

View File

@@ -9,12 +9,11 @@
<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";
</script> </script>
<script src="/api/v0/config/frontend.js" vite-ignore></script> <script src="/api/v0/config/frontend.js"></script>
</head> </head>
<body class="d-flex flex-column min-vh-100"> <body class="d-flex flex-column min-vh-100">
<noscript> <noscript>

File diff suppressed because it is too large Load Diff

View File

@@ -3,34 +3,33 @@
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build-dev": "vite build --mode development", "build-dev": "vite build --mode development --base=/app/",
"build": "vite build", "build": "vite build --base=/app/",
"preview": "vite preview --port 5050" "preview": "vite preview --port 5050"
}, },
"dependencies": { "dependencies": {
"@fontsource/nunito-sans": "^5.2.7", "@fontsource/nunito-sans": "^5.2.5",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^6.7.2",
"@kyvg/vue3-notification": "^3.4.2", "@kyvg/vue3-notification": "^3.4.1",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.1.0",
"@vojtechlanka/vue-tags-input": "^3.1.2", "@vojtechlanka/vue-tags-input": "^3.1.1",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.7",
"bootswatch": "^5.3.8", "bootswatch": "^5.3.7",
"cidr-tools": "^11.3.2", "flag-icons": "^7.3.2",
"flag-icons": "^7.5.0", "ip-address": "^10.0.1",
"ip-address": "^10.1.0", "is-cidr": "^5.1.1",
"is-cidr": "^6.0.3",
"is-ip": "^5.0.1", "is-ip": "^5.0.1",
"pinia": "^3.0.4", "pinia": "^3.0.2",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"vue": "^3.5.32", "vue": "^3.5.13",
"vue-i18n": "^11.3.2", "vue-i18n": "^11.1.3",
"vue-prism-component": "github:h44z/vue-prism-component", "vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^5.0.4" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^5.2.3",
"sass-embedded": "^1.99.0", "sass-embedded": "^1.86.3",
"vite": "^8.0.8" "vite": "6.3.4"
} }
} }

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { RouterLink, RouterView } from 'vue-router'; import { RouterLink, RouterView } from 'vue-router';
import {computed, getCurrentInstance, nextTick, onMounted, ref} from "vue"; import { computed, getCurrentInstance, onMounted, ref } from "vue";
import { authStore } from "./stores/auth"; import { authStore } from "./stores/auth";
import { securityStore } from "./stores/security"; import { securityStore } from "./stores/security";
import { settingsStore } from "@/stores/settings"; import { settingsStore } from "@/stores/settings";
@@ -11,13 +11,12 @@ const auth = authStore()
const sec = securityStore() const sec = securityStore()
const settings = settingsStore() const settings = settingsStore()
const currentTheme = ref("auto")
onMounted(async () => { onMounted(async () => {
console.log("Starting WireGuard Portal frontend..."); console.log("Starting WireGuard Portal frontend...");
// restore theme from localStorage // restore theme from localStorage
switchTheme(getTheme()); const theme = localStorage.getItem('wgTheme') || 'light';
document.documentElement.setAttribute('data-bs-theme', theme);
await sec.LoadSecurityProperties(); await sec.LoadSecurityProperties();
await auth.LoadProviders(); await auth.LoadProviders();
@@ -45,22 +44,10 @@ const switchLanguage = function (lang) {
} }
} }
const getTheme = function () {
return localStorage.getItem('wgTheme') || 'auto';
}
const switchTheme = function (theme) { const switchTheme = function (theme) {
let bsTheme = theme; if (document.documentElement.getAttribute('data-bs-theme') !== theme) {
if (theme === 'auto') {
bsTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
currentTheme.value = theme;
if (document.documentElement.getAttribute('data-bs-theme') !== bsTheme) {
console.log("Switching theme to " + theme + " (" + bsTheme + ")");
localStorage.setItem('wgTheme', theme); localStorage.setItem('wgTheme', theme);
document.documentElement.setAttribute('data-bs-theme', bsTheme); document.documentElement.setAttribute('data-bs-theme', theme);
} }
} }
@@ -76,7 +63,6 @@ const languageFlag = computed(() => {
uk: "ua", uk: "ua",
zh: "cn", zh: "cn",
ko: "kr", ko: "kr",
es: "es",
}; };
return "fi-" + (langMap[lang] || lang); return "fi-" + (langMap[lang] || lang);
@@ -85,7 +71,6 @@ 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";
@@ -114,7 +99,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">
<RouterLink class="navbar-brand" :to="{ name: 'home' }"><img :alt="companyName" :src="webBasePath + '/img/header-logo.png'" /></RouterLink> <a class="navbar-brand" href="/"><img :alt="companyName" src="/img/header-logo.png" /></a>
<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>
@@ -134,9 +119,6 @@ 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">
@@ -154,25 +136,20 @@ const userDisplayName = computed(() => {
<div v-if="!auth.IsAuthenticated" class="nav-item"> <div v-if="!auth.IsAuthenticated" class="nav-item">
<RouterLink :to="{ name: 'login' }" class="nav-link"><i class="fas fa-sign-in-alt fa-sm fa-fw me-2"></i>{{ $t('menu.login') }}</RouterLink> <RouterLink :to="{ name: 'login' }" class="nav-link"><i class="fas fa-sign-in-alt fa-sm fa-fw me-2"></i>{{ $t('menu.login') }}</RouterLink>
</div> </div>
<div class="nav-item dropdown" :key="currentTheme"> <div class="nav-item dropdown" data-bs-theme="light">
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="theme-menu" aria-expanded="false" data-bs-toggle="dropdown" data-bs-display="static" aria-label="Toggle theme"> <a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="theme-menu" aria-expanded="false" data-bs-toggle="dropdown" data-bs-display="static" aria-label="Toggle theme">
<i class="fa-solid fa-circle-half-stroke"></i> <i class="fa-solid fa-circle-half-stroke"></i>
<span class="d-lg-none ms-2">Toggle theme</span> <span class="d-lg-none ms-2">Toggle theme</span>
</a> </a>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li>
<button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('auto')" aria-pressed="false">
<i class="fa-solid fa-circle-half-stroke"></i><span class="ms-2">System</span><i class="fa-solid fa-check ms-5" :class="{invisible:currentTheme!=='auto'}"></i>
</button>
</li>
<li> <li>
<button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('light')" aria-pressed="false"> <button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('light')" aria-pressed="false">
<i class="fa-solid fa-sun"></i><span class="ms-2">Light</span><i class="fa-solid fa-check ms-5" :class="{invisible:currentTheme!=='light'}"></i> <i class="fa-solid fa-sun"></i><span class="ms-2">Light</span>
</button> </button>
</li> </li>
<li> <li>
<button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('dark')" aria-pressed="true"> <button type="button" class="dropdown-item d-flex align-items-center" @click.prevent="switchTheme('dark')" aria-pressed="true">
<i class="fa-solid fa-moon"></i><span class="ms-2">Dark</span><i class="fa-solid fa-check ms-5" :class="{invisible:currentTheme!=='dark'}"></i> <i class="fa-solid fa-moon"></i><span class="ms-2">Dark</span>
</button> </button>
</li> </li>
</ul> </ul>
@@ -205,7 +182,6 @@ const userDisplayName = computed(() => {
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('uk')"><span class="fi fi-ua"></span> Українська</a> <a class="dropdown-item" href="#" @click.prevent="switchLanguage('uk')"><span class="fi fi-ua"></span> Українська</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('vi')"><span class="fi fi-vi"></span> Tiếng Việt</a> <a class="dropdown-item" href="#" @click.prevent="switchLanguage('vi')"><span class="fi fi-vi"></span> Tiếng Việt</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('zh')"><span class="fi fi-cn"></span> 中文</a> <a class="dropdown-item" href="#" @click.prevent="switchLanguage('zh')"><span class="fi fi-cn"></span> 中文</a>
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('es')"><span class="fi fi-es"></span> Español</a>
</div> </div>
</div> </div>
@@ -243,8 +219,4 @@ const userDisplayName = computed(() => {
background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important; background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important;
color: var(--bs-badge-color)!important; color: var(--bs-badge-color)!important;
} }
[data-bs-theme=dark] .navbar-dark, .navbar {
background-color: #000 !important;
}
</style> </style>

View File

@@ -104,8 +104,4 @@ a.disabled {
.vue-tags-input .ti-deletion-mark:after { .vue-tags-input .ti-deletion-mark:after {
transform: scaleX(1); transform: scaleX(1);
}
.modal-dialog {
box-shadow: none;
} }

View File

@@ -53,7 +53,6 @@ const formData = ref(freshInterface())
const isSaving = ref(false) const isSaving = ref(false)
const isDeleting = ref(false) const isDeleting = ref(false)
const isApplyingDefaults = ref(false) const isApplyingDefaults = ref(false)
const isCreatingDefaultPeers = ref(false)
const isBackendValid = computed(() => { const isBackendValid = computed(() => {
if (!props.visible || !selectedInterface.value) { if (!props.visible || !selectedInterface.value) {
@@ -84,7 +83,6 @@ 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
@@ -124,7 +122,6 @@ 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
@@ -314,55 +311,11 @@ async function applyPeerDefaults() {
} }
} }
async function createDefaultPeers() {
if (props.interfaceId==='#NEW#') {
return; // do nothing for new interfaces
}
if (!formData.value.CreateDefaultPeer) {
return; // only allowed if the interface flag is set
}
if (isCreatingDefaultPeers.value) return
isCreatingDefaultPeers.value = true
try {
await interfaces.CreateDefaultPeers(selectedInterface.value.Identifier)
notify({
title: "Default Peers Created",
text: "Created default peers for all users on this interface.",
type: 'success',
})
await peers.LoadPeers(selectedInterface.value.Identifier) // reload peers list
} catch (e) {
console.log(e)
notify({
title: "Failed to create default peers!",
text: e.toString(),
type: 'error',
})
} finally {
isCreatingDefaultPeers.value = false
}
}
async function del() { async function del() {
if (isDeleting.value) return if (isDeleting.value) return
if (!confirm(t('modals.interface-edit.confirm-delete', {id: selectedInterface.value.Identifier}))) return
isDeleting.value = true isDeleting.value = true
try { try {
await interfaces.DeleteInterface(selectedInterface.value.Identifier) await interfaces.DeleteInterface(selectedInterface.value.Identifier)
// reload all interfaces and peers
await interfaces.LoadInterfaces()
if (interfaces.Count > 0 && interfaces.GetSelected !== undefined) {
const selectedInterface = interfaces.GetSelected
await peers.LoadPeers(selectedInterface.Identifier)
await peers.LoadStats(selectedInterface.Identifier)
} else {
await peers.Reset() // reset peers if no interfaces are available
}
close() close()
} catch (e) { } catch (e) {
console.log(e) console.log(e)
@@ -481,11 +434,6 @@ async function del() {
<label class="form-label mt-4">{{ $t('modals.interface-edit.firewall-mark.label') }}</label> <label class="form-label mt-4">{{ $t('modals.interface-edit.firewall-mark.label') }}</label>
<input v-model="formData.FirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number"> <input v-model="formData.FirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number">
</div> </div>
<div class="form-group col-md-6" v-if="formData.Backend!=='local'">
<label class="form-label mt-4">{{ $t('modals.interface-edit.routing-table.label') }}</label>
<input v-model="formData.RoutingTable" aria-describedby="routingTableHelp" class="form-control" :placeholder="$t('modals.interface-edit.routing-table.placeholder')" type="text">
<small id="routingTableHelp" class="form-text text-muted">{{ $t('modals.interface-edit.routing-table.description') }}</small>
</div>
<div class="form-group col-md-6" v-else> <div class="form-group col-md-6" v-else>
</div> </div>
</div> </div>
@@ -499,7 +447,7 @@ async function del() {
</div> </div>
</div> </div>
</fieldset> </fieldset>
<fieldset v-if="formData.Backend==='local'"> <fieldset>
<legend class="mt-4">{{ $t('modals.interface-edit.header-hooks') }}</legend> <legend class="mt-4">{{ $t('modals.interface-edit.header-hooks') }}</legend>
<div class="form-group"> <div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.pre-up.label') }}</label> <label class="form-label mt-4">{{ $t('modals.interface-edit.pre-up.label') }}</label>
@@ -524,17 +472,7 @@ 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="d-flex align-items-center justify-content-between" v-if="formData.Mode==='server' && settings.Setting('CreateDefaultPeer')"> <div class="form-check form-switch">
<div class="form-check form-switch mb-0">
<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>
<button v-if="props.interfaceId!=='#NEW#'" class="btn btn-primary btn-sm" type="button" @click.prevent="createDefaultPeers" :disabled="!formData.CreateDefaultPeer || isCreatingDefaultPeers">
<span v-if="isCreatingDefaultPeers" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
{{ $t('modals.interface-edit.button-create-default-peers') }}
</button>
</div>
<div class="form-check form-switch" v-if="formData.Backend==='local'">
<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>
</div> </div>

View File

@@ -26,13 +26,13 @@
display:block; display:block;
} }
.modal.show { .modal.show {
opacity: 1.0; opacity: 1;
} }
.modal-backdrop { .modal-backdrop {
background-color: rgba(0,0,0,0.6) !important; background-color: rgba(0,0,0,0.6) !important;
} }
.modal-backdrop.show { .modal-backdrop.show {
opacity: 1.0 !important; opacity: 1 !important;
} }
</style> </style>

View File

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

View File

@@ -294,7 +294,6 @@ async function save() {
async function del() { async function del() {
if (isDeleting.value) return if (isDeleting.value) return
if (!confirm(t('modals.peer-edit.confirm-delete', {id: selectedPeer.value.Identifier}))) return
isDeleting.value = true isDeleting.value = true
try { try {
await peers.DeletePeer(selectedPeer.value.Identifier) await peers.DeletePeer(selectedPeer.value.Identifier)
@@ -359,7 +358,7 @@ async function del() {
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint.placeholder')" <input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint.placeholder')"
v-model="formData.Endpoint.Value"> v-model="formData.Endpoint.Value">
</div> </div>
<div class="form-group" v-if="selectedInterface.Mode !== 'client'"> <div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-edit.ip.label') }}</label> <label class="form-label mt-4">{{ $t('modals.peer-edit.ip.label') }}</label>
<vue-tags-input class="form-control" v-model="currentTags.Addresses" <vue-tags-input class="form-control" v-model="currentTags.Addresses"
:tags="formData.Addresses.map(str => ({ text: str }))" :tags="formData.Addresses.map(str => ({ text: str }))"

View File

@@ -6,7 +6,6 @@ import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { freshInterface, freshPeer, freshStats } from '@/helpers/models'; import { freshInterface, freshPeer, freshStats } from '@/helpers/models';
import Prism from "vue-prism-component"; import Prism from "vue-prism-component";
import 'prismjs/components/prism-ini';
import { notify } from "@kyvg/vue3-notification"; import { notify } from "@kyvg/vue3-notification";
import { settingsStore } from "@/stores/settings"; import { settingsStore } from "@/stores/settings";
import { profileStore } from "@/stores/profile"; import { profileStore } from "@/stores/profile";
@@ -131,7 +130,7 @@ function ConfigQrUrl() {
<template> <template>
<Modal :title="title" :visible="visible" @close="close"> <Modal :title="title" :visible="visible" @close="close">
<template #default> <template #default>
<div class="d-flex justify-content-end align-items-center mb-1" v-if="selectedInterface.Mode !== 'client'"> <div class="d-flex justify-content-end align-items-center mb-1">
<span class="me-2">{{ $t('modals.peer-view.style-label') }}: </span> <span class="me-2">{{ $t('modals.peer-view.style-label') }}: </span>
<div class="btn-group btn-switch-group" role="group" aria-label="Configuration Style"> <div class="btn-group btn-switch-group" role="group" aria-label="Configuration Style">
<input type="radio" class="btn-check" name="configstyle" id="raw" value="raw" autocomplete="off" checked="" v-model="configStyle"> <input type="radio" class="btn-check" name="configstyle" id="raw" value="raw" autocomplete="off" checked="" v-model="configStyle">
@@ -152,28 +151,20 @@ function ConfigQrUrl() {
data-bs-parent="#peerInformation" style=""> data-bs-parent="#peerInformation" style="">
<div class="accordion-body"> <div class="accordion-body">
<div class="row"> <div class="row">
<div :class="{ 'col-md-8': selectedInterface.Mode !== 'client', 'col-md-12': selectedInterface.Mode !== 'server' }" class="col-md-8"> <div class="col-md-8">
<ul> <ul>
<li v-if="selectedInterface.Mode !== 'client'"><strong>{{ $t('modals.peer-view.identifier') }}</strong>: {{ selectedPeer.PublicKey }}</li> <li>{{ $t('modals.peer-view.identifier') }}: {{ selectedPeer.PublicKey }}</li>
<li v-if="selectedInterface.Mode !== 'server'"><strong>{{ $t('modals.peer-view.endpoint-key') }}</strong>: {{ selectedPeer.PublicKey }}</li> <li>{{ $t('modals.peer-view.ip') }}: <span v-for="ip in selectedPeer.Addresses" :key="ip"
<li v-if="selectedInterface.Mode !== 'server'"><strong>{{ $t('modals.peer-view.endpoint') }}</strong>: {{ selectedPeer.Endpoint.Value }}</li>
<li v-if="selectedInterface.Mode !== 'client'"><strong>{{ $t('modals.peer-view.ip') }}</strong>: <span v-for="ip in selectedPeer.Addresses" :key="ip"
class="badge rounded-pill bg-light">{{ ip }}</span></li> class="badge rounded-pill bg-light">{{ ip }}</span></li>
<li v-if="selectedInterface.Mode === 'server'"><strong>{{ $t('modals.peer-view.extra-allowed-ip') }}</strong>: <span v-for="ip in selectedPeer.ExtraAllowedIPs" :key="ip" <li>{{ $t('modals.peer-view.user') }}: {{ selectedPeer.UserIdentifier }}</li>
class="badge rounded-pill bg-light">{{ ip }}</span></li> <li v-if="selectedPeer.Notes">{{ $t('modals.peer-view.notes') }}: {{ selectedPeer.Notes }}</li>
<li v-if="selectedInterface.Mode !== 'server' && selectedPeer.AllowedIPs.Value"><strong>{{ $t('modals.peer-view.allowed-ip') }}</strong>: <span v-for="ip in selectedPeer.AllowedIPs.Value" :key="ip" <li v-if="selectedPeer.ExpiresAt">{{ $t('modals.peer-view.expiry-status') }}: {{
class="badge rounded-pill bg-light">{{ ip }}</span></li>
<li v-if="selectedInterface.Mode !== 'server'"><strong>{{ $t('modals.peer-view.keepalive') }}</strong>: {{ selectedPeer.PersistentKeepalive.Value }}</li>
<li v-if="selectedPeer.UserDisplayName"><strong>{{ $t('modals.peer-view.user') }}</strong>: {{ selectedPeer.UserDisplayName }} ({{ selectedPeer.UserIdentifier }})</li>
<li v-else><strong>{{ $t('modals.peer-view.user') }}</strong>: {{ selectedPeer.UserIdentifier }}</li>
<li v-if="selectedPeer.Notes"><strong>{{ $t('modals.peer-view.notes') }}</strong>: {{ selectedPeer.Notes }}</li>
<li v-if="selectedPeer.ExpiresAt"><strong>{{ $t('modals.peer-view.expiry-status') }}</strong>: {{
selectedPeer.ExpiresAt }}</li> selectedPeer.ExpiresAt }}</li>
<li v-if="selectedPeer.Disabled"><strong>{{ $t('modals.peer-view.disabled-status') }}</strong>: {{ <li v-if="selectedPeer.Disabled">{{ $t('modals.peer-view.disabled-status') }}: {{
selectedPeer.DisabledReason }}</li> selectedPeer.DisabledReason }}</li>
</ul> </ul>
</div> </div>
<div class="col-md-4" v-if="selectedInterface.Mode !== 'client'"> <div class="col-md-4">
<img class="config-qr-img" :src="ConfigQrUrl()" loading="lazy" alt="Configuration QR Code"> <img class="config-qr-img" :src="ConfigQrUrl()" loading="lazy" alt="Configuration QR Code">
</div> </div>
</div> </div>
@@ -208,7 +199,7 @@ function ConfigQrUrl() {
</div> </div>
</div> </div>
</div> </div>
<div v-if="selectedInterface.Mode !== 'client'" class="accordion-item"> <div v-if="selectedInterface.Mode === 'server'" class="accordion-item">
<h2 class="accordion-header" id="headingConfig"> <h2 class="accordion-header" id="headingConfig">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseConfig" aria-expanded="false" aria-controls="collapseConfig"> data-bs-target="#collapseConfig" aria-expanded="false" aria-controls="collapseConfig">
@@ -226,9 +217,9 @@ function ConfigQrUrl() {
</template> </template>
<template #footer> <template #footer>
<div class="flex-fill text-start"> <div class="flex-fill text-start">
<button v-if="selectedInterface.Mode !== 'client'" @click.prevent="download" type="button" class="btn btn-primary me-1">{{ <button @click.prevent="download" type="button" class="btn btn-primary me-1">{{
$t('modals.peer-view.button-download') }}</button> $t('modals.peer-view.button-download') }}</button>
<button v-if="selectedInterface.Mode !== 'client'" @click.prevent="email" type="button" class="btn btn-primary me-1">{{ <button @click.prevent="email" type="button" class="btn btn-primary me-1">{{
$t('modals.peer-view.button-email') }}</button> $t('modals.peer-view.button-email') }}</button>
</div> </div>
<button @click.prevent="close" type="button" class="btn btn-secondary">{{ $t('general.close') }}</button> <button @click.prevent="close" type="button" class="btn btn-secondary">{{ $t('general.close') }}</button>

View File

@@ -42,7 +42,7 @@ const passwordWeak = computed(() => {
}) })
const formValid = computed(() => { const formValid = computed(() => {
if (!formData.value.AuthSources.some(s => s === 'db')) { if (formData.value.Source !== '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.AuthSources = selectedUser.value.AuthSources formData.value.Source = selectedUser.value.Source
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,7 +80,6 @@ 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
} }
} }
} }
@@ -114,7 +113,6 @@ async function save() {
async function del() { async function del() {
if (isDeleting.value) return if (isDeleting.value) return
if (!confirm(t('modals.user-edit.confirm-delete', {id: selectedUser.value.Identifier}))) return
isDeleting.value = true isDeleting.value = true
try { try {
await users.DeleteUser(selectedUser.value.Identifier) await users.DeleteUser(selectedUser.value.Identifier)
@@ -135,7 +133,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> <fieldset v-if="formData.Source==='db'">
<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>
@@ -143,22 +141,16 @@ 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.AuthSources" class="form-control" disabled="disabled" :placeholder="$t('modals.user-edit.source.placeholder')" type="text"> <input v-model="formData.Source" class="form-control" disabled="disabled" :placeholder="$t('modals.user-edit.source.placeholder')" type="text">
</div> </div>
<div class="form-group" v-if="formData.AuthSources.some(s => s ==='db')"> <div v-if="formData.Source==='db'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label> <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.AuthSources.some(s => s !=='db') && !formData.PersistLocalChanges"> <fieldset v-if="formData.Source==='db'">
<legend class="mt-4">{{ $t('modals.user-edit.header-personal') }}</legend>
<div class="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>
@@ -202,14 +194,10 @@ 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.AuthSources.some(s => s !=='db') || formData.PersistLocalChanges"> <div class="form-check form-switch" v-if="formData.Source==='db'">
<input v-model="formData.IsAdmin" checked="" class="form-check-input" type="checkbox"> <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>

View File

@@ -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.replaceAll('+', '.') output = output.replace('+', '.')
output = output.replaceAll('/', '_') output = output.replace('/', '_')
output = output.replaceAll('=', '-') output = output.replace('=', '-')
return output return output
} }

View File

@@ -4,7 +4,6 @@ export function freshInterface() {
Disabled: false, Disabled: false,
DisplayName: "", DisplayName: "",
Identifier: "", Identifier: "",
CreateDefaultPeer: false,
Mode: "server", Mode: "server",
Backend: "local", Backend: "local",
@@ -54,7 +53,6 @@ export function freshPeer() {
Identifier: "", Identifier: "",
DisplayName: "", DisplayName: "",
UserIdentifier: "", UserIdentifier: "",
UserDisplayName: "",
InterfaceIdentifier: "", InterfaceIdentifier: "",
Disabled: false, Disabled: false,
ExpiresAt: null, ExpiresAt: null,
@@ -137,7 +135,7 @@ export function freshUser() {
Identifier: "", Identifier: "",
Email: "", Email: "",
AuthSources: ["db"], Source: "db",
IsAdmin: false, IsAdmin: false,
Firstname: "", Firstname: "",
@@ -155,8 +153,6 @@ export function freshUser() {
ApiEnabled: false, ApiEnabled: false,
PersistLocalChanges: false,
PeerCount: 0, PeerCount: 0,
// Internal values // Internal values

View File

@@ -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.bigInt() return addr.bigInteger()
} }
// 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.bigInt() return addr.bigInteger()
} }
export function humanFileSize(size) { export function humanFileSize(size) {

View File

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

View File

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

View File

@@ -42,8 +42,7 @@
"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",
@@ -118,7 +117,6 @@
"dns": "DNS-Server", "dns": "DNS-Server",
"mtu": "MTU", "mtu": "MTU",
"default-keep-alive": "Standard Keepalive-Intervall", "default-keep-alive": "Standard Keepalive-Intervall",
"default-dns": "Standard DNS-Server",
"button-show-config": "Konfiguration anzeigen", "button-show-config": "Konfiguration anzeigen",
"button-download-config": "Konfiguration herunterladen", "button-download-config": "Konfiguration herunterladen",
"button-store-config": "Konfiguration für wg-quick speichern", "button-store-config": "Konfiguration für wg-quick speichern",
@@ -129,11 +127,6 @@
"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",
@@ -147,7 +140,7 @@
"email": "E-Mail", "email": "E-Mail",
"firstname": "Vorname", "firstname": "Vorname",
"lastname": "Nachname", "lastname": "Nachname",
"sources": "Quellen", "source": "Quelle",
"peers": "Peers", "peers": "Peers",
"admin": "Admin" "admin": "Admin"
}, },
@@ -158,14 +151,6 @@
"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",
@@ -235,16 +220,6 @@
"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": {
@@ -283,26 +258,6 @@
"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:",
@@ -378,12 +333,7 @@
}, },
"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.",
"confirm-delete": "Benutzer '{id}' wirklich löschen?"
}, },
"interface-view": { "interface-view": {
"headline": "Konfiguration für Schnittstelle:" "headline": "Konfiguration für Schnittstelle:"
@@ -474,9 +424,6 @@
"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"
}, },
@@ -504,9 +451,7 @@
"placeholder": "Persistentes Keepalive (0 = Standard)" "placeholder": "Persistentes Keepalive (0 = Standard)"
} }
}, },
"button-apply-defaults": "Peer-Standardeinstellungen anwenden", "button-apply-defaults": "Peer-Standardeinstellungen anwenden"
"button-create-default-peers": "Standard-Peers erstellen",
"confirm-delete": "Interface '{id}' wirklich löschen?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Peer:", "headline-peer": "Peer:",
@@ -516,8 +461,6 @@
"section-config": "Konfiguration", "section-config": "Konfiguration",
"identifier": "Kennung", "identifier": "Kennung",
"ip": "IP-Adressen", "ip": "IP-Adressen",
"allowed-ip": "Erlaubte IP-Adressen",
"extra-allowed-ip": "Serverseitig erlaubte IP-Adressen",
"user": "Zugeordneter Benutzer", "user": "Zugeordneter Benutzer",
"notes": "Notizen", "notes": "Notizen",
"expiry-status": "Läuft ab am", "expiry-status": "Läuft ab am",
@@ -530,8 +473,6 @@
"handshake": "Letzter Handshake", "handshake": "Letzter Handshake",
"connected-since": "Verbunden seit", "connected-since": "Verbunden seit",
"endpoint": "Endpunkt", "endpoint": "Endpunkt",
"endpoint-key": "Öffentlicher Endpunkt-Schlüssel",
"keepalive": "Persistentes Keepalive",
"button-download": "Konfiguration herunterladen", "button-download": "Konfiguration herunterladen",
"button-email": "Konfiguration per E-Mail senden", "button-email": "Konfiguration per E-Mail senden",
"style-label": "Konfigurationsformat" "style-label": "Konfigurationsformat"
@@ -628,8 +569,7 @@
}, },
"expires-at": { "expires-at": {
"label": "Ablaufdatum" "label": "Ablaufdatum"
}, }
"confirm-delete": "Peer '{id}' wirklich löschen?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Mehrere Peers erstellen", "headline-peer": "Mehrere Peers erstellen",

View File

@@ -42,8 +42,7 @@
"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",
@@ -118,7 +117,6 @@
"dns": "DNS Servers", "dns": "DNS Servers",
"mtu": "MTU", "mtu": "MTU",
"default-keep-alive": "Default Keepalive Interval", "default-keep-alive": "Default Keepalive Interval",
"default-dns": "Default DNS Servers",
"button-show-config": "Show configuration", "button-show-config": "Show configuration",
"button-download-config": "Download configuration", "button-download-config": "Download configuration",
"button-store-config": "Store configuration for wg-quick", "button-store-config": "Store configuration for wg-quick",
@@ -129,11 +127,6 @@
"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",
@@ -147,7 +140,7 @@
"email": "E-Mail", "email": "E-Mail",
"firstname": "Firstname", "firstname": "Firstname",
"lastname": "Lastname", "lastname": "Lastname",
"sources": "Sources", "source": "Source",
"peers": "Peers", "peers": "Peers",
"admin": "Admin" "admin": "Admin"
}, },
@@ -158,14 +151,6 @@
"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",
@@ -235,16 +220,6 @@
"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": {
@@ -271,36 +246,16 @@
"headline-preshared-key": "New Preshared Key", "headline-preshared-key": "New Preshared Key",
"button-generate": "Generate", "button-generate": "Generate",
"private-key": { "private-key": {
"label": "Private Key", "label": "Private Key",
"placeholder": "The private key" "placeholder": "The private key"
}, },
"public-key": { "public-key": {
"label": "Public Key", "label": "Public Key",
"placeholder": "The public key" "placeholder": "The public key"
}, },
"preshared-key": { "preshared-key": {
"label": "Preshared Key", "label": "Preshared Key",
"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": {
@@ -378,12 +333,7 @@
}, },
"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.",
"confirm-delete": "Are you sure you want to delete user '{id}'?"
}, },
"interface-view": { "interface-view": {
"headline": "Config for Interface:" "headline": "Config for Interface:"
@@ -474,9 +424,6 @@
"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"
}, },
@@ -504,9 +451,8 @@
"placeholder": "Persistent Keepalive (0 = default)" "placeholder": "Persistent Keepalive (0 = default)"
} }
}, },
"button-apply-defaults": "Apply Peer Defaults",
"button-create-default-peers": "Create Default Peers", "button-apply-defaults": "Apply Peer Defaults"
"confirm-delete": "Are you sure you want to delete interface '{id}'?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Peer:", "headline-peer": "Peer:",
@@ -516,8 +462,6 @@
"section-config": "Configuration", "section-config": "Configuration",
"identifier": "Identifier", "identifier": "Identifier",
"ip": "IP Addresses", "ip": "IP Addresses",
"allowed-ip": "Allowed IP Addresses",
"extra-allowed-ip": "Server Side Allowed IP Addresses",
"user": "Associated User", "user": "Associated User",
"notes": "Notes", "notes": "Notes",
"expiry-status": "Expires At", "expiry-status": "Expires At",
@@ -530,8 +474,6 @@
"handshake": "Last Handshake", "handshake": "Last Handshake",
"connected-since": "Connected since", "connected-since": "Connected since",
"endpoint": "Endpoint", "endpoint": "Endpoint",
"endpoint-key": "Endpoint Public Key",
"keepalive": "Persistent Keepalive",
"button-download": "Download configuration", "button-download": "Download configuration",
"button-email": "Send configuration via E-Mail", "button-email": "Send configuration via E-Mail",
"style-label": "Configuration Style" "style-label": "Configuration Style"
@@ -628,8 +570,7 @@
}, },
"expires-at": { "expires-at": {
"label": "Expiry date" "label": "Expiry date"
}, }
"confirm-delete": "Are you sure you want to delete peer '{id}'?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Create multiple peers", "headline-peer": "Create multiple peers",

View File

@@ -1,639 +0,0 @@
{
"languages": {
"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": {
"pagination": {
"size": "Numero de elementos",
"all": "Todos (Lento)"
},
"search": {
"placeholder": "Buscar...",
"button": "Buscar"
},
"select-all": "Buscar todos",
"yes": "Sí",
"no": "No",
"cancel": "Cancelar",
"close": "Cerrar",
"save": "Guardar",
"delete": "Eliminar"
},
"login": {
"headline": "Por favor inicie sesión",
"username": {
"label": "Usuario",
"placeholder": "Por favor ingrese su usuario"
},
"password": {
"label": "Contraseña",
"placeholder": "Por favor ingrese su contraseña"
},
"button": "Ingresar",
"button-webauthn": "Usar clave de acceso"
},
"menu": {
"calculator": "Calculadora IP",
"home": "Inicio",
"interfaces": "Interfaces",
"users": "Usuarios",
"lang": "Cambiar idioma",
"profile": "Mi perfil",
"settings": "Configuración",
"audit": "Registro de auditoría",
"login": "Iniciar sesión",
"logout": "Cerrar sesión",
"keygen": "Generador de claves"
},
"home": {
"headline": "Portal VPN WireGuard®",
"info-headline": "Más información",
"abstract": "WireGuard® es una VPN extremadamente simple pero rápida y moderna que utiliza criptografía de última generación. Su objetivo es ser más rápida, simple, ligera y útil que IPsec, a la vez que evita los enormes problemas que supone. Su objetivo es ofrecer un rendimiento considerablemente superior al de OpenVPN.",
"installation": {
"box-header": "Instalación de WireGuard",
"headline": "Instalación",
"content": "Las instrucciones de instalación del cliente se pueden encontrar en el sitio web oficial de WireGuard.",
"button": "Abrir instrucciones"
},
"about-wg": {
"box-header": "Acerca de WireGuard",
"headline": "Acerca de",
"content": "WireGuard® es una VPN extremadamente simple pero rápida y moderna que utiliza criptografía de última generación.",
"button": "Más"
},
"about-portal": {
"box-header": "Acerca del Portal WireGuard",
"headline": "Portal WireGuard",
"content": "WireGuard Portal es un portal web simple para la configuración de WireGuard.",
"button": "Más"
},
"profiles": {
"headline": "Perfiles VPN",
"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.",
"button": "Abrir mi perfil"
},
"admin": {
"headline": "Área de administración",
"abstract": "En el área de administración puedes gestionar los peers de WireGuard, la interfaz del servidor, así como los usuarios que tienen acceso al Portal WireGuard.",
"content": "",
"button-admin": "Abrir administración del servidor",
"button-user": "Abrir administración de usuarios"
}
},
"interfaces": {
"headline": "Administración de interfaces",
"headline-peers": "Peers VPN actuales",
"headline-endpoints": "Extremos actuales",
"no-interface": {
"default-selection": "No hay interfaces disponibles",
"headline": "No se encontraron interfaces...",
"abstract": "Haz clic en el botón + para crear una nueva interfaz WireGuard."
},
"no-peer": {
"headline": "No hay peers disponibles",
"abstract": "Actualmente no hay peers disponibles para la interfaz WireGuard seleccionada."
},
"table-heading": {
"name": "Nombre",
"user": "Usuario",
"ip": "IPs",
"endpoint": "Endpoint",
"status": "Estado"
},
"interface": {
"headline": "Estado de la interfaz para",
"backend": "Backend",
"unknown-backend": "Desconocido",
"wrong-backend": "Backend inválido, usando backend local de WireGuard en su lugar.",
"key": "Clave pública",
"endpoint": "Endpoint público",
"port": "Puerto de escucha",
"peers": "Peers habilitados",
"total-peers": "Peers totales",
"endpoints": "Endpoints habilitados",
"total-endpoints": "Endpoints totales",
"ip": "Dirección IP",
"default-allowed-ip": "IPs permitidas por defecto",
"default-dns": "Servidores DNS por defecto",
"dns": "Servidores DNS",
"mtu": "MTU",
"default-keep-alive": "Intervalo Keepalive por defecto",
"button-show-config": "Mostrar configuración",
"button-download-config": "Descargar configuración",
"button-store-config": "Guardar configuración para wg-quick",
"button-edit": "Editar interfaz"
},
"button-add-interface": "Agregar interfaz",
"button-add-peer": "Agregar peer",
"button-add-peers": "Agregar múltiples peers",
"button-show-peer": "Mostrar peer",
"button-edit-peer": "Editar peer",
"peer-disabled": "Peer deshabilitado, motivo:",
"peer-expiring": "El peer expira en",
"peer-connected": "Conectado",
"peer-not-connected": "No conectado",
"peer-handshake": "Último handshake:"
},
"users": {
"headline": "Administración de usuarios",
"table-heading": {
"id": "ID",
"email": "Correo electrónico",
"firstname": "Nombre",
"lastname": "Apellido",
"sources": "Origen",
"peers": "Peers",
"admin": "Administrador"
},
"no-user": {
"headline": "No hay usuarios disponibles",
"abstract": "Actualmente no hay usuarios registrados en el Portal WireGuard."
},
"button-add-user": "Agregar usuario",
"button-show-user": "Mostrar usuario",
"button-edit-user": "Editar usuario",
"user-disabled": "Usuario deshabilitado, motivo:",
"user-locked": "Cuenta bloqueada, motivo:",
"admin": "El usuario tiene privilegios de administrador",
"no-admin": "El usuario no tiene privilegios de administrador"
},
"profile": {
"headline": "Mis peers VPN",
"table-heading": {
"name": "Nombre",
"ip": "IPs",
"stats": "Estado",
"interface": "Interfaz del servidor"
},
"no-peer": {
"headline": "No hay peers disponibles",
"abstract": "Actualmente no hay peers asociados a tu perfil de usuario."
},
"peer-connected": "Conectado",
"button-add-peer": "Agregar peer",
"button-show-peer": "Mostrar peer",
"button-edit-peer": "Editar peer"
},
"settings": {
"headline": "Configuración",
"abstract": "Aquí puedes cambiar tu configuración personal.",
"api": {
"headline": "Configuración de API",
"abstract": "Aquí puedes configurar los ajustes de la API RESTful.",
"active-description": "La API está actualmente activa para tu cuenta. Todas las solicitudes están autenticadas con Basic Auth. Usa las siguientes credenciales.",
"inactive-description": "La API está actualmente inactiva. Presiona el botón de abajo para activarla.",
"user-label": "Usuario de la API:",
"user-placeholder": "Usuario de la API",
"token-label": "Contraseña de la API:",
"token-placeholder": "Token de la API",
"token-created-label": "Acceso API concedido en: ",
"button-disable-title": "Desactivar API, invalidará el token actual.",
"button-disable-text": "Desactivar API",
"button-enable-title": "Activar API, generará un nuevo token.",
"button-enable-text": "Activar API",
"api-link": "Documentación de API"
},
"webauthn": {
"headline": "Configuración de llave de acceso",
"abstract": "Las llaves de acceso son una forma moderna de autenticar usuarios sin necesidad de contraseñas. Se almacenan de forma segura en tu navegador y pueden usarse para iniciar sesión en el Portal WireGuard.",
"active-description": "Al menos una llave de acceso está activa en tu cuenta.",
"inactive-description": "Actualmente no hay llaves de acceso registradas. Presiona el botón de abajo para registrar una.",
"table": {
"name": "Nombre",
"created": "Creada",
"actions": ""
},
"credentials-list": "Llaves de acceso registradas actualmente",
"modal-delete": {
"headline": "Eliminar llaves de acceso",
"abstract": "¿Seguro que deseas eliminar esta llave de acceso? Ya no podrás usarla para iniciar sesión.",
"created": "Creada:",
"button-delete": "Eliminar",
"button-cancel": "Cancelar"
},
"button-rename-title": "Renombrar",
"button-rename-text": "Renombrar la llave de acceso.",
"button-save-title": "Guardar",
"button-save-text": "Guardar el nuevo nombre de la llave de acceso.",
"button-cancel-title": "Cancelar",
"button-cancel-text": "Cancelar el renombrado de la llave de acceso.",
"button-delete-title": "Eliminar",
"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-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": {
"headline": "Registro de Auditoría",
"abstract": "Aquí puedes encontrar el registro de auditoría de todas las acciones realizadas en el Portal WireGuard.",
"no-entries": {
"headline": "No hay entradas en el registro",
"abstract": "Actualmente no se han registrado auditorías."
},
"entries-headline": "Entradas del Registro",
"table-heading": {
"id": "#",
"time": "Hora",
"user": "Usuario",
"severity": "Severidad",
"origin": "Origen",
"message": "Mensaje"
}
},
"keygen": {
"headline": "Generador de claves WireGuard",
"abstract": "Genera nuevas claves de WireGuard. Las claves se generan en tu navegador local y nunca se envían al servidor.",
"headline-keypair": "Nuevo par de claves",
"headline-preshared-key": "Nueva clave pre-compartida",
"button-generate": "Generar",
"private-key": {
"label": "Clave privada",
"placeholder": "La clave privada"
},
"public-key": {
"label": "Clave pública",
"placeholder": "La clave pública"
},
"preshared-key": {
"label": "Clave pre-compartida",
"placeholder": "La clave pre-compartida"
}
},
"modals": {
"user-view": {
"headline": "Cuenta de Usuario:",
"tab-user": "Información",
"tab-peers": "Peers",
"headline-info": "Información del Usuario:",
"headline-notes": "Notas:",
"email": "Correo Electrónico",
"firstname": "Nombre",
"lastname": "Apellido",
"phone": "Número de Teléfono",
"department": "Departamento",
"api-enabled": "Acceso API",
"disabled": "Cuenta Deshabilitada",
"locked": "Cuenta Bloqueada",
"no-peers": "El usuario no tiene peers asociados.",
"peers": {
"name": "Nombre",
"interface": "Interfaz",
"ip": "IPs"
}
},
"user-edit": {
"headline-edit": "Editar usuario:",
"headline-new": "Nuevo usuario",
"header-general": "General",
"header-personal": "Información del Usuario",
"header-notes": "Notas",
"header-state": "Estado",
"identifier": {
"label": "Identificador",
"placeholder": "El identificador único del usuario"
},
"source": {
"label": "Origen",
"placeholder": "El origen del usuario"
},
"password": {
"label": "Contraseña",
"placeholder": "Una contraseña súper segura",
"description": "Deja este campo en blanco para mantener la contraseña actual.",
"too-weak": "La contraseña es demasiado débil. Por favor usa una más fuerte."
},
"email": {
"label": "Correo",
"placeholder": "La dirección de correo"
},
"phone": {
"label": "Teléfono",
"placeholder": "El número de teléfono"
},
"department": {
"label": "Departamento",
"placeholder": "El departamento"
},
"firstname": {
"label": "Nombre",
"placeholder": "Nombre"
},
"lastname": {
"label": "Apellido",
"placeholder": "Apellido"
},
"notes": {
"label": "Notas",
"placeholder": ""
},
"disabled": {
"label": "Deshabilitado (sin conexión WireGuard y sin posibilidad de inicio de sesión)"
},
"locked": {
"label": "Bloqueado (no es posible iniciar sesión, las conexiones WireGuard aún funcionan)"
},
"admin": {
"label": "Es administrador"
},
"confirm-delete": "Seguro que desea eliminar el usuario '{id}'?"
},
"interface-view": {
"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": {
"headline-edit": "Editar interfaz:",
"headline-new": "Nueva interfaz",
"tab-interface": "Interfaz",
"tab-peerdef": "Valores predeterminados del peer",
"header-general": "General",
"header-network": "Red",
"header-crypto": "Criptografía",
"header-hooks": "Hooks de interfaz",
"header-peer-hooks": "Hooks",
"header-state": "Estado",
"identifier": {
"label": "Identificador",
"placeholder": "El identificador único de la interfaz"
},
"mode": {
"label": "Modo de Interfaz",
"server": "Modo Servidor",
"client": "Modo Cliente",
"any": "Modo Desconocido"
},
"backend": {
"label": "Backend de la Interfaz",
"invalid-label": "El backend original ya no está disponible, usando el backend local de WireGuard en su lugar.",
"local": "Backend local de WireGuard"
},
"display-name": {
"label": "Nombre para Mostrar",
"placeholder": "El nombre descriptivo de la interfaz"
},
"private-key": {
"label": "La clave Privada",
"placeholder": "La clave privada"
},
"public-key": {
"label": "La clave pública",
"placeholder": "La clave pública"
},
"ip": {
"label": "Direcciones IP",
"placeholder": "Direcciones IP (formato CIDR)"
},
"listen-port": {
"label": "Puerto de Escucha",
"placeholder": "El puerto de escucha"
},
"dns": {
"label": "Servidor DNS",
"placeholder": "Los servidores DNS que deben usarse"
},
"dns-search": {
"label": "Dominios de Búsqueda DNS",
"placeholder": "Prefijos de búsqueda DNS"
},
"mtu": {
"label": "MTU",
"placeholder": "La MTU de la interfaz (0 = mantener por defecto)"
},
"firewall-mark": {
"label": "Marca de Firewall",
"placeholder": "Marca de firewall que se aplica al tráfico saliente. (0 = automático)"
},
"routing-table": {
"label": "Tabla de Enrutamiento",
"placeholder": "El ID de la tabla de enrutamiento",
"description": "Casos especiales: off = no administrar rutas, 0 = automático"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "Uno o varios comandos bash separados por ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "Uno o varios comandos bash separados por ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "Uno o varios comandos bash separados por ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "Uno o varios comandos bash separados por ;"
},
"disabled": {
"label": "Interfaz Deshabilitada"
},
"save-config": {
"label": "Guardar automáticamente la configuración de wg-quick"
},
"defaults": {
"endpoint": {
"label": "Dirección del Endpoint",
"placeholder": "Dirección del Endpoint",
"description": "La dirección del endpoint al que los peers se conectarán. (ej: wg.ejemplo.com o wg.ejemplo.com:51820)"
},
"networks": {
"label": "Redes IP",
"placeholder": "Direcciones de Red",
"description": "Los peers obtendrán direcciones IP de esas subredes."
},
"allowed-ip": {
"label": "Direcciones IP Permitidas",
"placeholder": "Direcciones IP Permitidas por Defecto"
},
"mtu": {
"label": "MTU",
"placeholder": "La MTU del cliente (0 = mantener por defecto)"
},
"keep-alive": {
"label": "Intervalo de Keep Alive",
"placeholder": "Keepalive Persistente (0 = por defecto)"
}
},
"button-apply-defaults": "Aplicar Valores Predeterminados de peers",
"button-create-default-peers": "Crear Peers Predeterminados",
"confirm-delete": "Seguro que desea eliminar la interfaz '{id}'?"
},
"peer-view": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"section-info": "Información del peer",
"section-status": "Estado Actual",
"section-config": "Configuración",
"identifier": "Identificador",
"ip": "Direcciones IP",
"allowed-ip": "Direcciones IP permitidas",
"extra-allowed-ip": "Direcciones IP permitidas del lado del servidor",
"user": "Usuario Asociado",
"notes": "Notas",
"expiry-status": "Expira en",
"disabled-status": "Deshabilitado en",
"traffic": "Tráfico",
"connection-status": "Estadísticas de Conexión",
"upload": "Bytes Subidos (del Servidor al peer)",
"download": "Bytes Descargados (del peer al Servidor)",
"pingable": "Alcanzable (ping)",
"handshake": "Último handshake",
"connected-since": "Conectado desde",
"endpoint": "Dirección del host remoto",
"button-download": "Descargar configuración",
"button-email": "Enviar configuración por Correo Electrónico",
"style-label": "Estilo de Configuración"
},
"peer-edit": {
"headline-edit-peer": "Editar peer:",
"headline-edit-endpoint": "Editar endpoint:",
"headline-new-peer": "Crear peer",
"headline-new-endpoint": "Crear endpoint",
"header-general": "General",
"header-network": "Red",
"header-crypto": "Criptografía",
"header-hooks": "Hooks (Ejecutados en el peer)",
"header-state": "Estado",
"display-name": {
"label": "Nombre para mostrar",
"placeholder": "El nombre descriptivo para el peer"
},
"linked-user": {
"label": "Usuario Vinculado",
"placeholder": "La cuenta de usuario que posee este peer"
},
"private-key": {
"label": "Clave Privada",
"placeholder": "Clave privada",
"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": {
"label": "Clave Pública",
"placeholder": "La Clave pública"
},
"preshared-key": {
"label": "Clave pre-compartida",
"placeholder": "Clave pre-compartida opcional"
},
"endpoint": {
"label": "Dirección del endpoint",
"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": {
"label": "Direcciones IP",
"placeholder": "Direcciones IP (formato CIDR)"
},
"allowed-ip": {
"label": "Direcciones IP permitidas",
"placeholder": "Direcciones IP permitidas (formato CIDR)"
},
"extra-allowed-ip": {
"label": "Direcciones IP permitidas extra",
"placeholder": "IPs extra permitidas (lado del servidor)",
"description": "Esas IPs serán agregadas en la interfaz remota de WireGuard como direcciones IP permitidas."
},
"dns": {
"label": "Servidor DNS",
"placeholder": "Los servidores DNS que deben usarse"
},
"dns-search": {
"label": "Dominios de búsqueda DNS",
"placeholder": "Prefijos de búsqueda DNS"
},
"keep-alive": {
"label": "Intervalo de Keep Alive",
"placeholder": "Keepalive Persistente (0 = por defecto)"
},
"mtu": {
"label": "MTU",
"placeholder": "La MTU del cliente (0 = mantener por defecto)"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "Uno o varios comandos bash separados por ;"
},
"post-up": {
"label": "Post-Up",
"placeholder": "Uno o varios comandos bash separados por ;"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "Uno o varios comandos bash separados por ;"
},
"post-down": {
"label": "Post-Down",
"placeholder": "Uno o varios comandos bash separados por ;"
},
"disabled": {
"label": "Peer Deshabilitado"
},
"ignore-global": {
"label": "Ignorar configuración global"
},
"expires-at": {
"label": "Fecha de expiración"
},
"confirm-delete": "Seguro que desea eliminar el par '{id}'?"
},
"peer-multi-create": {
"headline-peer": "Crear múltiples peers",
"headline-endpoint": "Crear múltiples endpoints",
"identifiers": {
"label": "Identificadores de Usuario",
"placeholder": "Identificadores de Usuario",
"description": "Un identificador de usuario (el nombre de usuario) para el cual debe crearse un peer."
},
"prefix": {
"headline-peer": "Peer:",
"headline-endpoint": "Endpoint:",
"label": "Prefijo del nombre del peer a mostrar",
"placeholder": "Prefijo",
"description": "Un prefijo que se agregará al nombre visible de los peers."
}
}
}
}

View File

@@ -126,7 +126,9 @@
"peer-expiring": "Le pair expire le", "peer-expiring": "Le pair expire le",
"peer-connected": "Connecté", "peer-connected": "Connecté",
"peer-not-connected": "Non connecté", "peer-not-connected": "Non connecté",
"peer-handshake": "Dernière négociation :" "peer-handshake": "Dernière négociation :",
"button-show-peer": "Afficher le pair",
"button-edit-peer": "Modifier le pair"
}, },
"users": { "users": {
"headline": "Administration des utilisateurs", "headline": "Administration des utilisateurs",
@@ -135,7 +137,7 @@
"email": "E-mail", "email": "E-mail",
"firstname": "Prénom", "firstname": "Prénom",
"lastname": "Nom", "lastname": "Nom",
"sources": "Sources", "source": "Source",
"peers": "Pairs", "peers": "Pairs",
"admin": "Admin" "admin": "Admin"
}, },
@@ -262,8 +264,7 @@
}, },
"admin": { "admin": {
"label": "Est Admin" "label": "Est Admin"
}, }
"confirm-delete": "Voulez-vous vraiment supprimer l'utilisateur \"{id}\" ?"
}, },
"interface-view": { "interface-view": {
"headline": "Configuration pour l'interface :" "headline": "Configuration pour l'interface :"
@@ -376,9 +377,7 @@
"placeholder": "Persistent Keepalive (0 = par défaut)" "placeholder": "Persistent Keepalive (0 = par défaut)"
} }
}, },
"button-apply-defaults": "Appliquer les valeurs par défaut des pairs", "button-apply-defaults": "Appliquer les valeurs par défaut des pairs"
"button-create-default-peers": "Créer les pairs par défaut",
"confirm-delete": "Voulez-vous vraiment supprimer l'interface \"{id}\" ?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Pair :", "headline-peer": "Pair :",
@@ -494,8 +493,7 @@
}, },
"expires-at": { "expires-at": {
"label": "Date d'expiration" "label": "Date d'expiration"
}, }
"confirm-delete": "Voulez-vous vraiment supprimer le pair \"{id}\" ?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Créer plusieurs pairs", "headline-peer": "Créer plusieurs pairs",

View File

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

View File

@@ -136,7 +136,7 @@
"email": "이메일", "email": "이메일",
"firstname": "이름", "firstname": "이름",
"lastname": "성", "lastname": "성",
"sources": "소스", "source": "소스",
"peers": "피어", "peers": "피어",
"admin": "관리자" "admin": "관리자"
}, },
@@ -282,7 +282,6 @@
"label": "관리자 여부" "label": "관리자 여부"
} }
}, },
"confirm-delete": "사용자 '{id}'를 삭제하시겠습니까?",
"interface-view": { "interface-view": {
"headline": "인터페이스 구성:" "headline": "인터페이스 구성:"
}, },
@@ -394,9 +393,7 @@
"placeholder": "영구 Keepalive (0 = 기본값)" "placeholder": "영구 Keepalive (0 = 기본값)"
} }
}, },
"button-apply-defaults": "피어 기본값 적용", "button-apply-defaults": "피어 기본값 적용"
"button-create-default-peers": "기본 피어 생성",
"confirm-delete": "인터페이스 '{id}'를 삭제하시겠습니까?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "피어:", "headline-peer": "피어:",
@@ -512,8 +509,7 @@
}, },
"expires-at": { "expires-at": {
"label": "만료 날짜" "label": "만료 날짜"
}, }
"confirm-delete": "피어 '{id}'를 삭제하시겠습니까?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "여러 피어 생성", "headline-peer": "여러 피어 생성",

View File

@@ -137,7 +137,7 @@
"email": "E-Mail", "email": "E-Mail",
"firstname": "Primeiro Nome", "firstname": "Primeiro Nome",
"lastname": "Último Nome", "lastname": "Último Nome",
"sources": "Fonte", "source": "Fonte",
"peers": "Peers", "peers": "Peers",
"admin": "Administrador" "admin": "Administrador"
}, },
@@ -300,8 +300,7 @@
}, },
"admin": { "admin": {
"label": "É Administrador" "label": "É Administrador"
}, }
"confirm-delete": "Tem certeza que deseja excluir o utilizador '{id}'?"
}, },
"interface-view": { "interface-view": {
"headline": "Configuração para a Interface:" "headline": "Configuração para a Interface:"
@@ -414,9 +413,7 @@
"placeholder": "Keepalive persistente (0 = padrão)" "placeholder": "Keepalive persistente (0 = padrão)"
} }
}, },
"button-apply-defaults": "Aplicar Padrões de Peer", "button-apply-defaults": "Aplicar Padrões de Peer"
"button-create-default-peers": "Criar Peers Padrão",
"confirm-delete": "Tem certeza que deseja excluir a interface '{id}'?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Peer:", "headline-peer": "Peer:",
@@ -533,8 +530,7 @@
}, },
"expires-at": { "expires-at": {
"label": "Data de expiração" "label": "Data de expiração"
}, }
"confirm-delete": "Tem certeza que deseja excluir o par '{id}'?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Criar múltiplos peers", "headline-peer": "Criar múltiplos peers",

View File

@@ -29,8 +29,7 @@
"label": "Пароль", "label": "Пароль",
"placeholder": "Пожалуйста, введите ваш пароль" "placeholder": "Пожалуйста, введите ваш пароль"
}, },
"button": "Войти", "button": "Войти"
"button-webauthn": "Использовать Passkey"
}, },
"menu": { "menu": {
"home": "Главная", "home": "Главная",
@@ -38,12 +37,8 @@
"users": "Пользователи", "users": "Пользователи",
"lang": "Сменить язык", "lang": "Сменить язык",
"profile": "Мой профиль", "profile": "Мой профиль",
"settings": "Настройки",
"audit": "Журнал аудита",
"login": "Вход", "login": "Вход",
"logout": "Выход", "logout": "Выход"
"keygen": "Генератор ключей",
"calculator": "Калькулятор IP-адресов"
}, },
"home": { "home": {
"headline": "Портал VPN WireGuard®", "headline": "Портал VPN WireGuard®",
@@ -105,8 +100,6 @@
"interface": { "interface": {
"headline": "Статус интерфейса для", "headline": "Статус интерфейса для",
"backend": "бэкэнд", "backend": "бэкэнд",
"unknown-backend": "Неизвестно",
"wrong-backend": "Неверный бэкэнд, вместо него используется локальный сервер WireGuard!",
"key": "Публичный ключ", "key": "Публичный ключ",
"endpoint": "Публичная конечная точка", "endpoint": "Публичная конечная точка",
"port": "Порт прослушивания", "port": "Порт прослушивания",
@@ -119,7 +112,6 @@
"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",
@@ -143,7 +135,7 @@
"email": "Электронная почта", "email": "Электронная почта",
"firstname": "Имя", "firstname": "Имя",
"lastname": "Фамилия", "lastname": "Фамилия",
"sources": "Источник", "source": "Источник",
"peers": "Пиры", "peers": "Пиры",
"admin": "Админ" "admin": "Админ"
}, },
@@ -176,121 +168,6 @@
"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": "Учетная запись пользователя:",
@@ -303,7 +180,6 @@
"lastname": "Фамилия", "lastname": "Фамилия",
"phone": "Номер телефона", "phone": "Номер телефона",
"department": "Отдел", "department": "Отдел",
"api-enabled": "API",
"disabled": "Учетная запись отключена", "disabled": "Учетная запись отключена",
"locked": "Учетная запись заблокирована", "locked": "Учетная запись заблокирована",
"no-peers": "У пользователя нет связанных пиров.", "no-peers": "У пользователя нет связанных пиров.",
@@ -331,8 +207,7 @@
"password": { "password": {
"label": "Пароль", "label": "Пароль",
"placeholder": "Надежный пароль", "placeholder": "Надежный пароль",
"description": "Оставьте это поле пустым, чтобы сохранить текущий пароль.", "description": "Оставьте это поле пустым, чтобы сохранить текущий пароль."
"too-weak": "Пароль слишком простой. Используйте более сложный пароль."
}, },
"email": { "email": {
"label": "Электронная почта", "label": "Электронная почта",
@@ -366,8 +241,7 @@
}, },
"admin": { "admin": {
"label": "Является администратором" "label": "Является администратором"
}, }
"confirm-delete": "Вы уверены, что хотите удалить пользователя «{id}»?"
}, },
"interface-view": { "interface-view": {
"headline": "Конфигурация интерфейса:" "headline": "Конфигурация интерфейса:"
@@ -393,11 +267,6 @@
"client": "Режим клиента", "client": "Режим клиента",
"any": "Неизвестный режим" "any": "Неизвестный режим"
}, },
"backend": {
"label": "Бэкэнд интерфейса",
"invalid-label": "Оригинальный бэкэнд больше недоступн, вместо нее используется локальная WireGuard-бэкэнд!",
"local": "Локальный WireGuard-бэкэнд"
},
"display-name": { "display-name": {
"label": "Отображаемое имя", "label": "Отображаемое имя",
"placeholder": "Описательное имя для интерфейса" "placeholder": "Описательное имя для интерфейса"
@@ -485,9 +354,7 @@
"placeholder": "Постоянное поддержание активности (0 = значение по умолчанию)" "placeholder": "Постоянное поддержание активности (0 = значение по умолчанию)"
} }
}, },
"button-apply-defaults": "Применить настройки пира по умолчанию", "button-apply-defaults": "Применить настройки пира по умолчанию"
"button-create-default-peers": "Создать пиров по умолчанию",
"confirm-delete": "Вы уверены, что хотите удалить интерфейс «{id}»?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Пир:", "headline-peer": "Пир:",
@@ -497,8 +364,6 @@
"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": "Истекает в",
@@ -511,10 +376,8 @@
"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": "Редактировать пира:",
@@ -536,8 +399,7 @@
}, },
"private-key": { "private-key": {
"label": "Приватный ключ", "label": "Приватный ключ",
"placeholder": "Приватный ключ", "placeholder": "Приватный ключ"
"help": "Закрытый ключ надежно хранится на сервере. Если у пользователя уже есть копия, вы можете не указывать это поле. Сервер работает исключительно с открытым ключом клиента."
}, },
"public-key": { "public-key": {
"label": "Публичный ключ", "label": "Публичный ключ",
@@ -569,62 +431,61 @@
"description": "Эти IP-адреса будут добавлены в удаленный интерфейс WireGuard как разрешенные IP-адреса." "description": "Эти IP-адреса будут добавлены в удаленный интерфейс WireGuard как разрешенные IP-адреса."
}, },
"dns": { "dns": {
"label": "DNS-сервер", "label": "DNS Server",
"placeholder": "Используемые DNS-серверы" "placeholder": "The DNS servers that should be used"
}, },
"dns-search": { "dns-search": {
"label": "Поисковые домены DNS", "label": "DNS Search Domains",
"placeholder": "Префиксы поиска DNS" "placeholder": "DNS search prefixes"
}, },
"keep-alive": { "keep-alive": {
"label": "Интервал поддержания активности", "label": "Keep Alive Interval",
"placeholder": "Постоянное поддержание активности (0 = значение по умолчанию)" "placeholder": "Persistent Keepalive (0 = default)"
}, },
"mtu": { "mtu": {
"label": "MTU", "label": "MTU",
"placeholder": "MTU клиента (0 = использовать значение по умолчанию)" "placeholder": "The client MTU (0 = keep default)"
}, },
"pre-up": { "pre-up": {
"label": "Pre-Up", "label": "Pre-Up",
"placeholder": "Одна или несколько команд bash, разделенных ;" "placeholder": "One or multiple bash commands separated by ;"
}, },
"post-up": { "post-up": {
"label": "Post-Up", "label": "Post-Up",
"placeholder": "Одна или несколько команд bash, разделенных ;" "placeholder": "One or multiple bash commands separated by ;"
}, },
"pre-down": { "pre-down": {
"label": "Pre-Down", "label": "Pre-Down",
"placeholder": "Одна или несколько команд bash, разделенных ;" "placeholder": "One or multiple bash commands separated by ;"
}, },
"post-down": { "post-down": {
"label": "Post-Down", "label": "Post-Down",
"placeholder": "Одна или несколько команд bash, разделенных ;" "placeholder": "One or multiple bash commands separated by ;"
}, },
"disabled": { "disabled": {
"label": "Узел отключен" "label": "Peer Disabled"
}, },
"ignore-global": { "ignore-global": {
"label": "Игнорировать глобальные настройки" "label": "Ignore global settings"
}, },
"expires-at": { "expires-at": {
"label": "Дата истечения срока действия" "label": "Expiry date"
}, }
"confirm-delete": "Вы уверены, что хотите удалить пир «{id}»?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Создать несколько узлов", "headline-peer": "Create multiple peers",
"headline-endpoint": "Создать несколько конечных точек", "headline-endpoint": "Create multiple endpoints",
"identifiers": { "identifiers": {
"label": "Идентификаторы пользователей", "label": "User Identifiers",
"placeholder": "Идентификаторы пользователей", "placeholder": "User Identifiers",
"description": "Идентификатор пользователя (имя пользователя), для которого узел будет создан." "description": "A user identifier (the username) for which a peer should be created."
}, },
"prefix": { "prefix": {
"headline-peer": "Узел:", "headline-peer": "Peer:",
"headline-endpoint": "Конечная точка:", "headline-endpoint": "Endpoint:",
"label": "Префикс отображаемого имени", "label": "Display Name Prefix",
"placeholder": "Префикс", "placeholder": "The prefix",
"description": "Префикс будет добавлен к отображаемому имени узла." "description": "A prefix that is added to the peers display name."
} }
} }
} }

View File

@@ -135,7 +135,7 @@
"email": "E-Mail", "email": "E-Mail",
"firstname": "Ім'я", "firstname": "Ім'я",
"lastname": "Прізвище", "lastname": "Прізвище",
"sources": "Джерело", "source": "Джерело",
"peers": "Піри", "peers": "Піри",
"admin": "Адміністратор" "admin": "Адміністратор"
}, },
@@ -151,6 +151,7 @@
"admin": "Користувач має адміністративні привілеї", "admin": "Користувач має адміністративні привілеї",
"no-admin": "Користувач не має адміністративних привілеїв" "no-admin": "Користувач не має адміністративних привілеїв"
}, },
"profile": { "profile": {
"headline": "Мої VPN-піри", "headline": "Мої VPN-піри",
"table-heading": { "table-heading": {
@@ -188,6 +189,7 @@
"api-link": "Документація API" "api-link": "Документація API"
} }
}, },
"modals": { "modals": {
"user-view": { "user-view": {
"headline": "Обліковий запис користувача:", "headline": "Обліковий запис користувача:",
@@ -262,8 +264,7 @@
}, },
"admin": { "admin": {
"label": "Адміністратор" "label": "Адміністратор"
}, }
"confirm-delete": "Ви впевнені, що хочете видалити користувача «{id}»?"
}, },
"interface-view": { "interface-view": {
"headline": "Конфігурація для інтерфейсу:" "headline": "Конфігурація для інтерфейсу:"
@@ -376,9 +377,7 @@
"placeholder": "Постійний Keepalive (0 = за замовчуванням)" "placeholder": "Постійний Keepalive (0 = за замовчуванням)"
} }
}, },
"button-apply-defaults": "Застосувати значення за замовчуванням для пірів", "button-apply-defaults": "Застосувати значення за замовчуванням для пірів"
"button-create-default-peers": "Створити пірів за замовчуванням",
"confirm-delete": "Ви впевнені, що хочете видалити інтерфейс «{id}»?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Пір:", "headline-peer": "Пір:",
@@ -494,8 +493,7 @@
}, },
"expires-at": { "expires-at": {
"label": "Дата закінчення терміну дії" "label": "Дата закінчення терміну дії"
}, }
"confirm-delete": "Ви впевнені, що хочете видалити пір «{id}»?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Створити декілька пір", "headline-peer": "Створити декілька пір",

View File

@@ -134,7 +134,7 @@
"email": "E-Mail", "email": "E-Mail",
"firstname": "Tên", "firstname": "Tên",
"lastname": "Họ", "lastname": "Họ",
"sources": "Nguồn", "source": "Nguồn",
"peers": "Peers", "peers": "Peers",
"admin": "Quản trị viên" "admin": "Quản trị viên"
}, },
@@ -240,8 +240,7 @@
}, },
"admin": { "admin": {
"label": "Là Quản trị viên" "label": "Là Quản trị viên"
}, }
"confirm-delete": "Ban co chac muon xoa nguoi dung '{id}' khong?"
}, },
"interface-view": { "interface-view": {
"headline": "Cấu hình cho Giao diện:" "headline": "Cấu hình cho Giao diện:"
@@ -354,9 +353,8 @@
"placeholder": "Giữ kết nối liên tục (0 = mặc định)" "placeholder": "Giữ kết nối liên tục (0 = mặc định)"
} }
}, },
"button-apply-defaults": "Áp dụng Cài đặt Mặc định của Peer",
"button-create-default-peers": "Tạo Peer Mặc định", "button-apply-defaults": "Áp dụng Cài đặt Mặc định của Peer"
"confirm-delete": "Ban co chac muon xoa giao dien '{id}' khong?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Peer:", "headline-peer": "Peer:",
@@ -472,8 +470,7 @@
}, },
"expires-at": { "expires-at": {
"label": "Ngày hết hạn" "label": "Ngày hết hạn"
}, }
"confirm-delete": "Ban co chac muon xoa peer '{id}' khong?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Tạo nhiều peer", "headline-peer": "Tạo nhiều peer",

View File

@@ -134,7 +134,7 @@
"email": "电子邮件", "email": "电子邮件",
"firstname": "名", "firstname": "名",
"lastname": "姓", "lastname": "姓",
"sources": "来源", "source": "来源",
"peers": "节点", "peers": "节点",
"admin": "管理员" "admin": "管理员"
}, },
@@ -242,7 +242,6 @@
"label": "管理员" "label": "管理员"
} }
}, },
"confirm-delete": "确定要删除用户“{id}”吗?",
"interface-view": { "interface-view": {
"headline": "接口配置: " "headline": "接口配置: "
}, },
@@ -354,9 +353,7 @@
"placeholder": "持久保持连接 (0 = 默认)" "placeholder": "持久保持连接 (0 = 默认)"
} }
}, },
"button-apply-defaults": "应用节点默认值", "button-apply-defaults": "应用节点默认值"
"button-create-default-peers": "创建默认节点",
"confirm-delete": "确定要删除接口“{id}”吗?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "节点: ", "headline-peer": "节点: ",
@@ -472,8 +469,7 @@
}, },
"expires-at": { "expires-at": {
"label": "过期日期" "label": "过期日期"
}, }
"confirm-delete": "确定要删除对等点“{id}”吗?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "创建多个节点", "headline-peer": "创建多个节点",

View File

@@ -1,15 +1,14 @@
import {createRouter, createWebHashHistory} from 'vue-router' import {createRouter, createWebHashHistory} from 'vue-router'
import HomeView from '../views/HomeView.vue' import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue' import LoginView from '../views/LoginView.vue'
import InterfaceView from '../views/InterfaceView.vue'
import {authStore} from '@/stores/auth' import {authStore} from '@/stores/auth'
import {securityStore} from '@/stores/security' import {securityStore} from '@/stores/security'
import {notify} from "@kyvg/vue3-notification"; import {notify} from "@kyvg/vue3-notification";
const routerBase = `${WGPORTAL_BASE_PATH || ''}${import.meta.env.BASE_URL || '/'}`
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(routerBase), history: createWebHashHistory(),
routes: [ routes: [
{ {
path: '/', path: '/',
@@ -21,6 +20,11 @@ const router = createRouter({
name: 'login', name: 'login',
component: LoginView component: LoginView
}, },
{
path: '/interface',
name: 'interface',
component: InterfaceView
},
{ {
path: '/interfaces', path: '/interfaces',
name: 'interfaces', name: 'interfaces',
@@ -68,14 +72,6 @@ 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",
@@ -126,7 +122,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', '/ip-calculator'] const publicPages = ['/', '/login', '/key-generator']
const authRequired = !publicPages.includes(to.path) const authRequired = !publicPages.includes(to.path)
if (authRequired && !auth.IsAuthenticated) { if (authRequired && !auth.IsAuthenticated) {

View File

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

View File

@@ -2,7 +2,6 @@ import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification"; import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '@/helpers/fetch-wrapper' import { apiWrapper } from '@/helpers/fetch-wrapper'
import { websocketWrapper } from '@/helpers/websocket-wrapper'
import router from '../router' import router from '../router'
import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser'; import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser';
import {base64_url_encode} from "@/helpers/encoding"; import {base64_url_encode} from "@/helpers/encoding";
@@ -108,19 +107,12 @@ export const authStore = defineStore('auth',{
this.setUserInfo(null) this.setUserInfo(null)
this.ResetReturnUrl() // just to be sure^^ this.ResetReturnUrl() // just to be sure^^
let logoutResponse = null
try { try {
logoutResponse = await apiWrapper.post(`/auth/logout`) await apiWrapper.post(`/auth/logout`)
} catch (e) { } catch (e) {
console.log("Logout request failed:", e) console.log("Logout request failed:", e)
} }
const redirectUrl = logoutResponse?.RedirectUrl
if (redirectUrl) {
window.location.href = redirectUrl
return
}
notify({ notify({
title: "Logged Out", title: "Logged Out",
text: "Logout successful!", text: "Logout successful!",
@@ -303,11 +295,9 @@ export const authStore = defineStore('auth',{
} }
} }
localStorage.setItem('user', JSON.stringify(this.user)) localStorage.setItem('user', JSON.stringify(this.user))
websocketWrapper.connect()
} else { } else {
this.user = null this.user = null
localStorage.removeItem('user') localStorage.removeItem('user')
websocketWrapper.disconnect()
} }
}, },
setWebAuthnCredentials(credentials) { setWebAuthnCredentials(credentials) {

View File

@@ -14,7 +14,6 @@ export const interfaceStore = defineStore('interfaces', {
configuration: "", configuration: "",
selected: "", selected: "",
fetching: false, fetching: false,
trafficStats: {},
}), }),
getters: { getters: {
Count: (state) => state.interfaces.length, Count: (state) => state.interfaces.length,
@@ -25,9 +24,6 @@ export const interfaceStore = defineStore('interfaces', {
}, },
GetSelected: (state) => state.interfaces.find((i) => i.Identifier === state.selected) || state.interfaces[0], GetSelected: (state) => state.interfaces.find((i) => i.Identifier === state.selected) || state.interfaces[0],
isFetching: (state) => state.fetching, isFetching: (state) => state.fetching,
TrafficStats: (state) => {
return (state.selected in state.trafficStats) ? state.trafficStats[state.selected] : { Received: 0, Transmitted: 0 }
},
}, },
actions: { actions: {
setInterfaces(interfaces) { setInterfaces(interfaces) {
@@ -38,14 +34,6 @@ export const interfaceStore = defineStore('interfaces', {
this.selected = "" this.selected = ""
} }
this.fetching = false this.fetching = false
this.trafficStats = {}
},
updateInterfaceTrafficStats(interfaceStats) {
const id = interfaceStats.EntityId;
this.trafficStats[id] = {
Received: interfaceStats.BytesReceived,
Transmitted: interfaceStats.BytesTransmitted,
};
}, },
async LoadInterfaces() { async LoadInterfaces() {
this.fetching = true this.fetching = true
@@ -127,7 +115,6 @@ export const interfaceStore = defineStore('interfaces', {
return apiWrapper.post(`${baseUrl}/new`, formData) return apiWrapper.post(`${baseUrl}/new`, formData)
.then(iface => { .then(iface => {
this.interfaces.push(iface) this.interfaces.push(iface)
this.selected = iface.Identifier
this.fetching = false this.fetching = false
}) })
.catch(error => { .catch(error => {
@@ -148,18 +135,6 @@ export const interfaceStore = defineStore('interfaces', {
throw new Error(error) throw new Error(error)
}) })
}, },
async CreateDefaultPeers(id) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/create-default-peers`)
.then(() => {
this.fetching = false
})
.catch(error => {
this.fetching = false
console.log(error)
throw new Error(error)
})
},
async SaveConfiguration(id) { async SaveConfiguration(id) {
this.fetching = true this.fetching = true
return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/save-config`) return apiWrapper.post(`${baseUrl}/${base64_url_encode(id)}/save-config`)

View File

@@ -19,10 +19,10 @@ export const peerStore = defineStore('peers', {
filter: "", filter: "",
pageSize: 10, pageSize: 10,
pageOffset: 0, pageOffset: 0,
pages: [],
fetching: false, fetching: false,
sortKey: 'IsConnected', // Default sort key sortKey: 'IsConnected', // Default sort key
sortOrder: -1, // 1 for ascending, -1 for descending sortOrder: -1, // 1 for ascending, -1 for descending
trafficStats: {},
}), }),
getters: { getters: {
Find: (state) => { Find: (state) => {
@@ -76,9 +76,6 @@ export const peerStore = defineStore('peers', {
Statistics: (state) => { Statistics: (state) => {
return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats() return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats()
}, },
TrafficStats: (state) => {
return (id) => (id in state.trafficStats) ? state.trafficStats[id] : { Received: 0, Transmitted: 0 }
},
hasStatistics: (state) => state.statsEnabled, hasStatistics: (state) => state.statsEnabled,
}, },
@@ -86,24 +83,34 @@ export const peerStore = defineStore('peers', {
afterPageSizeChange() { afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes // reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0 this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
}, },
gotoPage(page) { gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
}, },
nextPage() { nextPage() {
if (this.hasNextPage) { this.pageOffset += this.pageSize
this.pageOffset += this.pageSize
} this.calculatePages()
}, },
previousPage() { previousPage() {
if (this.hasPrevPage) { this.pageOffset -= this.pageSize
this.pageOffset -= this.pageSize
} this.calculatePages()
}, },
setPeers(peers) { setPeers(peers) {
this.peers = peers this.peers = peers
this.calculatePages()
this.fetching = false this.fetching = false
this.trafficStats = {}
}, },
setPeer(peer) { setPeer(peer) {
this.peer = peer this.peer = peer
@@ -119,22 +126,9 @@ export const peerStore = defineStore('peers', {
if (!statsResponse) { if (!statsResponse) {
this.stats = {} this.stats = {}
this.statsEnabled = false this.statsEnabled = false
this.trafficStats = {}
} else {
this.stats = statsResponse.Stats
this.statsEnabled = statsResponse.Enabled
} }
}, this.stats = statsResponse.Stats
updatePeerTrafficStats(peerStats) { this.statsEnabled = statsResponse.Enabled
const id = peerStats.EntityId;
this.trafficStats[id] = {
Received: peerStats.BytesReceived,
Transmitted: peerStats.BytesTransmitted,
};
},
async Reset() {
this.setPeers([])
this.setStats(undefined)
}, },
async PreparePeer(interfaceId) { async PreparePeer(interfaceId) {
return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/prepare`) return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/prepare`)
@@ -192,10 +186,10 @@ export const peerStore = defineStore('peers', {
async LoadStats(interfaceId) { async LoadStats(interfaceId) {
// if no interfaceId is given, use the currently selected interface // if no interfaceId is given, use the currently selected interface
if (!interfaceId) { if (!interfaceId) {
if (!interfaceStore().GetSelected || !interfaceStore().GetSelected.Identifier) {
return // no interface, nothing to load
}
interfaceId = interfaceStore().GetSelected.Identifier interfaceId = interfaceStore().GetSelected.Identifier
if (!interfaceId) {
return // no interface, nothing to load
}
} }
this.fetching = true this.fetching = true
@@ -223,73 +217,6 @@ 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)
@@ -333,10 +260,10 @@ export const peerStore = defineStore('peers', {
async LoadPeers(interfaceId) { async LoadPeers(interfaceId) {
// if no interfaceId is given, use the currently selected interface // if no interfaceId is given, use the currently selected interface
if (!interfaceId) { if (!interfaceId) {
if (!interfaceStore().GetSelected || !interfaceStore().GetSelected.Identifier) { interfaceId = interfaceStore().GetSelected.Identifier
if (!interfaceId) {
return // no interface, nothing to load return // no interface, nothing to load
} }
interfaceId = interfaceStore().GetSelected.Identifier
} }
this.fetching = true this.fetching = true

View File

@@ -2,7 +2,6 @@ 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';
@@ -20,6 +19,7 @@ export const profileStore = defineStore('profile', {
filter: "", filter: "",
pageSize: 10, pageSize: 10,
pageOffset: 0, pageOffset: 0,
pages: [],
fetching: false, fetching: false,
sortKey: 'IsConnected', // Default sort key sortKey: 'IsConnected', // Default sort key
sortOrder: -1, // 1 for ascending, -1 for descending sortOrder: -1, // 1 for ascending, -1 for descending
@@ -73,25 +73,34 @@ export const profileStore = defineStore('profile', {
}, },
hasStatistics: (state) => state.statsEnabled, hasStatistics: (state) => state.statsEnabled,
CountInterfaces: (state) => state.interfaces.length, CountInterfaces: (state) => state.interfaces.length,
HasInterface: (state) => (id) => state.interfaces.some((i) => i.Identifier === id),
}, },
actions: { actions: {
afterPageSizeChange() { afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes // reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0 this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredPeerCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
}, },
gotoPage(page) { gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
}, },
nextPage() { nextPage() {
if (this.hasNextPage) { this.pageOffset += this.pageSize
this.pageOffset += this.pageSize
} this.calculatePages()
}, },
previousPage() { previousPage() {
if (this.hasPrevPage) { this.pageOffset -= this.pageSize
this.pageOffset -= this.pageSize
} this.calculatePages()
}, },
setPeers(peers) { setPeers(peers) {
this.peers = peers this.peers = peers
@@ -142,17 +151,6 @@ 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
@@ -209,18 +207,5 @@ 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)
})
},
} }
}) })

View File

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

View File

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

View File

@@ -1,139 +0,0 @@
<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>

View File

@@ -1,10 +1,9 @@
<script setup> <script setup>
import PeerViewModal from "@/components/PeerViewModal.vue"; import PeerViewModal from "../components/PeerViewModal.vue";
import PeerEditModal from "@/components/PeerEditModal.vue"; import PeerEditModal from "../components/PeerEditModal.vue";
import PeerMultiCreateModal from "@/components/PeerMultiCreateModal.vue"; import PeerMultiCreateModal from "../components/PeerMultiCreateModal.vue";
import InterfaceEditModal from "@/components/InterfaceEditModal.vue"; import InterfaceEditModal from "../components/InterfaceEditModal.vue";
import InterfaceViewModal from "@/components/InterfaceViewModal.vue"; import InterfaceViewModal from "../components/InterfaceViewModal.vue";
import Pagination from "@/components/Pagination.vue";
import {computed, onMounted, ref} from "vue"; import {computed, onMounted, ref} from "vue";
import {peerStore} from "@/stores/peers"; import {peerStore} from "@/stores/peers";
@@ -30,10 +29,6 @@ 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
@@ -116,39 +111,6 @@ 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;
@@ -211,12 +173,6 @@ onMounted(async () => {
<div class="col-12 col-lg-8"> <div class="col-12 col-lg-8">
{{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{ $t('modals.interface-edit.mode.' + interfaces.GetSelected.Mode )}} | {{ $t('interfaces.interface.backend') + ": " + calculateBackendName }}<span v-if="!isBackendValid" :title="t('interfaces.interface.wrong-backend')" class="ms-1 me-1"><i class="fa-solid fa-triangle-exclamation"></i></span>) {{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{ $t('modals.interface-edit.mode.' + interfaces.GetSelected.Mode )}} | {{ $t('interfaces.interface.backend') + ": " + calculateBackendName }}<span v-if="!isBackendValid" :title="t('interfaces.interface.wrong-backend')" class="ms-1 me-1"><i class="fa-solid fa-triangle-exclamation"></i></span>)
<span v-if="interfaces.GetSelected.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="interfaces.GetSelected.DisabledReason"></i></span> <span v-if="interfaces.GetSelected.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="interfaces.GetSelected.DisabledReason"></i></span>
<div v-if="interfaces.GetSelected && (interfaces.TrafficStats.Received > 0 || interfaces.TrafficStats.Transmitted > 0)" class="mt-2">
<small class="text-muted">
Traffic: <i class="fa-solid fa-arrow-down me-1"></i>{{ humanFileSize(interfaces.TrafficStats.Received) }}/s
<i class="fa-solid fa-arrow-up ms-1 me-1"></i>{{ humanFileSize(interfaces.TrafficStats.Transmitted) }}/s
</small>
</div>
</div> </div>
<div class="col-12 col-lg-4 text-lg-end"> <div class="col-12 col-lg-4 text-lg-end">
<a class="btn-link" href="#" :title="$t('interfaces.interface.button-show-config')" @click.prevent="viewedInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-eye"></i></a> <a class="btn-link" href="#" :title="$t('interfaces.interface.button-show-config')" @click.prevent="viewedInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-eye"></i></a>
@@ -262,12 +218,12 @@ onMounted(async () => {
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Addresses" :key="addr">{{addr}}</span></td> <td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Addresses" :key="addr">{{addr}}</span></td>
</tr> </tr>
<tr> <tr>
<td>{{ $t('interfaces.interface.mtu') }}:</td> <td>{{ $t('interfaces.interface.dns') }}:</td>
<td>{{interfaces.GetSelected.Mtu}}</td> <td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Dns" :key="addr">{{addr}}</span></td>
</tr> </tr>
<tr> <tr>
<td>{{ $t('interfaces.interface.default-dns') }}:</td> <td>{{ $t('interfaces.interface.mtu') }}:</td>
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.PeerDefDns" :key="addr">{{addr}}</span></td> <td>{{interfaces.GetSelected.Mtu}}</td>
</tr> </tr>
<tr> <tr>
<td>{{ $t('interfaces.interface.default-keep-alive') }}:</td> <td>{{ $t('interfaces.interface.default-keep-alive') }}:</td>
@@ -397,13 +353,6 @@ 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>
@@ -451,26 +400,21 @@ onMounted(async () => {
<span v-if="!peer.Disabled && peer.ExpiresAt" class="text-warning" :title="$t('interfaces.peer-expiring') + ' ' + peer.ExpiresAt"><i class="fas fa-hourglass-end expiring-peer"></i></span> <span v-if="!peer.Disabled && peer.ExpiresAt" class="text-warning" :title="$t('interfaces.peer-expiring') + ' ' + peer.ExpiresAt"><i class="fas fa-hourglass-end expiring-peer"></i></span>
</td> </td>
<td><span v-if="peer.DisplayName" :title="peer.Identifier">{{peer.DisplayName}}</span><span v-else :title="peer.Identifier">{{ $filters.truncate(peer.Identifier, 10)}}</span></td> <td><span v-if="peer.DisplayName" :title="peer.Identifier">{{peer.DisplayName}}</span><span v-else :title="peer.Identifier">{{ $filters.truncate(peer.Identifier, 10)}}</span></td>
<td><span :title="peer.UserDisplayName">{{peer.UserIdentifier}}</span></td> <td>{{peer.UserIdentifier}}</td>
<td> <td>
<span v-for="ip in peer.Addresses" :key="ip" class="badge bg-light me-1">{{ ip }}</span> <span v-for="ip in peer.Addresses" :key="ip" class="badge bg-light me-1">{{ ip }}</span>
</td> </td>
<td v-if="interfaces.GetSelected.Mode==='client'">{{peer.Endpoint.Value}}</td> <td v-if="interfaces.GetSelected.Mode==='client'">{{peer.Endpoint.Value}}</td>
<td v-if="peers.hasStatistics"> <td v-if="peers.hasStatistics">
<div v-if="peers.Statistics(peer.Identifier).IsConnected"> <div v-if="peers.Statistics(peer.Identifier).IsConnected">
<span class="badge rounded-pill bg-success" :title="$t('interfaces.peer-connected')"><i class="fa-solid fa-link"></i></span> <small class="text-muted" :title="$t('interfaces.peer-handshake') + ' ' + peers.Statistics(peer.Identifier).LastHandshake"><i class="fa-solid fa-circle-info"></i></small> <span class="badge rounded-pill bg-success" :title="$t('interfaces.peer-connected')"><i class="fa-solid fa-link"></i></span> <span :title="$t('interfaces.peer-handshake') + ' ' + peers.Statistics(peer.Identifier).LastHandshake">{{ $t('interfaces.peer-connected') }}</span>
</div> </div>
<div v-else> <div v-else>
<span class="badge rounded-pill bg-light" :title="$t('interfaces.peer-not-connected')"><i class="fa-solid fa-link-slash"></i></span> <span class="badge rounded-pill bg-light" :title="$t('interfaces.peer-not-connected')"><i class="fa-solid fa-link-slash"></i></span>
</div> </div>
</td> </td>
<td v-if="peers.hasStatistics" > <td v-if="peers.hasStatistics" >
<div class="d-flex flex-column"> <span class="text-center" >{{ humanFileSize(peers.Statistics(peer.Identifier).BytesReceived) }} / {{ humanFileSize(peers.Statistics(peer.Identifier).BytesTransmitted) }}</span>
<span :title="humanFileSize(peers.Statistics(peer.Identifier).BytesReceived) + ' / ' + humanFileSize(peers.Statistics(peer.Identifier).BytesTransmitted)">
<i class="fa-solid fa-arrow-down me-1"></i>{{ humanFileSize(peers.TrafficStats(peer.Identifier).Received) }}/s
<i class="fa-solid fa-arrow-up ms-1 me-1"></i>{{ humanFileSize(peers.TrafficStats(peer.Identifier).Transmitted) }}/s
</span>
</div>
</td> </td>
<td class="text-center"> <td class="text-center">
<a href="#" :title="$t('interfaces.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a> <a href="#" :title="$t('interfaces.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a>
@@ -483,23 +427,26 @@ onMounted(async () => {
<hr v-if="interfaces.Count!==0"> <hr v-if="interfaces.Count!==0">
<div v-if="interfaces.Count!==0" class="mt-3"> <div v-if="interfaces.Count!==0" class="mt-3">
<div class="row"> <div class="row">
<div class="col-12 col-md-6"> <div class="col-6">
<Pagination <ul class="pagination pagination-sm">
:currentPage="peers.currentPage" <li :class="{disabled:peers.pageOffset===0}" class="page-item">
:totalCount="peers.FilteredCount" <a class="page-link" @click="peers.previousPage">&laquo;</a>
:pageSize="peers.pageSize" </li>
:hasNextPage="peers.hasNextPage"
:hasPrevPage="peers.hasPrevPage" <li v-for="page in peers.pages" :key="page" :class="{active:peers.currentPage===page}" class="page-item">
:onGotoPage="peers.gotoPage" <a class="page-link" @click="peers.gotoPage(page)">{{page}}</a>
:onNextPage="peers.nextPage" </li>
:onPrevPage="peers.previousPage"
/> <li :class="{disabled:!peers.hasNextPage}" class="page-item">
<a class="page-link" @click="peers.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label> <label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="peers.pageSize" class="form-select" @change="peers.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="peers.pageSize" class="form-select" @click="peers.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>

View File

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

View File

@@ -1,20 +1,14 @@
<script setup> <script setup>
import PeerViewModal from "../components/PeerViewModal.vue"; import PeerViewModal from "../components/PeerViewModal.vue";
import { onMounted, ref, computed } from "vue"; import { onMounted, ref } 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 Pagination from "@/components/Pagination.vue";
import { settingsStore } from "@/stores/settings"; import { settingsStore } from "@/stores/settings";
import { humanFileSize } from "@/helpers/utils"; import { humanFileSize } from "@/helpers/utils";
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("")
@@ -23,10 +17,6 @@ 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
@@ -45,17 +35,6 @@ 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;
@@ -67,6 +46,7 @@ onMounted(async () => {
await profile.LoadPeers() await profile.LoadPeers()
await profile.LoadStats() await profile.LoadStats()
await profile.LoadInterfaces() await profile.LoadInterfaces()
await profile.calculatePages(); // Forces to show initial page number
}) })
</script> </script>
@@ -80,8 +60,6 @@ onMounted(async () => {
<div class="col-12 col-lg-5"> <div class="col-12 col-lg-5">
<h2 class="mt-2">{{ $t('profile.headline') }}</h2> <h2 class="mt-2">{{ $t('profile.headline') }}</h2>
</div> </div>
<div class="col-12 col-lg-3 text-lg-end" v-if="!settings.Setting('SelfProvisioning') || profile.CountInterfaces===0">
</div>
<div class="col-12 col-lg-4 text-lg-end"> <div class="col-12 col-lg-4 text-lg-end">
<div class="form-group d-inline"> <div class="form-group d-inline">
<div class="input-group mb-3"> <div class="input-group mb-3">
@@ -92,8 +70,8 @@ onMounted(async () => {
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-lg-3 text-lg-end" v-if="settings.Setting('SelfProvisioning') && profile.CountInterfaces>0"> <div class="col-12 col-lg-3 text-lg-end">
<div class="form-group"> <div class="form-group" v-if="settings.Setting('SelfProvisioning')">
<div class="input-group mb-3"> <div class="input-group mb-3">
<button class="btn btn-primary" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId = '#NEW#'"> <button class="btn btn-primary" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId = '#NEW#'">
<i class="fa fa-plus me-1"></i><i class="fa fa-user"></i> <i class="fa fa-plus me-1"></i><i class="fa fa-user"></i>
@@ -106,13 +84,6 @@ 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>
@@ -162,7 +133,8 @@ onMounted(async () => {
</td> </td>
<td v-if="profile.hasStatistics"> <td v-if="profile.hasStatistics">
<div v-if="profile.Statistics(peer.Identifier).IsConnected"> <div v-if="profile.Statistics(peer.Identifier).IsConnected">
<span class="badge rounded-pill bg-success" :title="$t('profile.peer-connected')"><i class="fa-solid fa-link"></i></span> <small class="text-muted" :title="$t('interfaces.peer-handshake') + ' ' + profile.Statistics(peer.Identifier).LastHandshake"><i class="fa-solid fa-circle-info"></i></small> <span class="badge rounded-pill bg-success"><i class="fa-solid fa-link"></i></span>
<span :title="profile.Statistics(peer.Identifier).LastHandshake">{{ $t('profile.peer-connected') }}</span>
</div> </div>
<div v-else> <div v-else>
<span class="badge rounded-pill bg-light"><i class="fa-solid fa-link-slash"></i></span> <span class="badge rounded-pill bg-light"><i class="fa-solid fa-link-slash"></i></span>
@@ -175,7 +147,7 @@ onMounted(async () => {
<td class="text-center"> <td class="text-center">
<a href="#" :title="$t('profile.button-show-peer')" @click.prevent="viewedPeerId = peer.Identifier"><i <a href="#" :title="$t('profile.button-show-peer')" @click.prevent="viewedPeerId = peer.Identifier"><i
class="fas fa-eye me-2"></i></a> class="fas fa-eye me-2"></i></a>
<a href="#" :title="$t('profile.button-edit-peer')" @click.prevent="editPeerId = peer.Identifier" v-if="settings.Setting('SelfProvisioning') && profile.HasInterface(peer.InterfaceIdentifier)"><i <a href="#" :title="$t('profile.button-edit-peer')" @click.prevent="editPeerId = peer.Identifier"><i
class="fas fa-cog"></i></a> class="fas fa-cog"></i></a>
</td> </td>
</tr> </tr>
@@ -185,33 +157,36 @@ onMounted(async () => {
<hr> <hr>
<div class="mt-3"> <div class="mt-3">
<div class="row"> <div class="row">
<div class="col-12 col-md-6"> <div class="col-6">
<Pagination <ul class="pagination pagination-sm">
:currentPage="profile.currentPage" <li :class="{ disabled: profile.pageOffset === 0 }" class="page-item">
:totalCount="profile.FilteredPeerCount" <a class="page-link" @click="profile.previousPage">&laquo;</a>
:pageSize="profile.pageSize" </li>
:hasNextPage="profile.hasNextPage"
:hasPrevPage="profile.hasPrevPage" <li v-for="page in profile.pages" :key="page" :class="{ active: profile.currentPage === page }" class="page-item">
:onGotoPage="profile.gotoPage" <a class="page-link" @click="profile.gotoPage(page)">{{ page }}</a>
:onNextPage="profile.nextPage" </li>
:onPrevPage="profile.previousPage"
/> <li :class="{ disabled: !profile.hasNextPage }" class="page-item">
<a class="page-link" @click="profile.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector"> <label class="col-sm-6 col-form-label text-end" for="paginationSelector">
{{ $t('general.pagination.size')}}: {{ $t('general.pagination.size')}}:
</label> </label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="profile.pageSize" class="form-select" @change="profile.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="profile.pageSize" class="form-select" @click="profile.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
<option value="100">100</option> <option value="100">100</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option> <option value="999999999">{{ $t('general.pagination.all') }}</option>
</select> </select>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div></template> </div></template>

View File

@@ -1,16 +1,13 @@
<script setup> <script setup>
import {computed, onMounted, ref} from "vue"; import {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()
@@ -37,45 +34,6 @@ 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>
@@ -85,45 +43,52 @@ const updatePassword = async () => {
<p class="lead">{{ $t('settings.abstract') }}</p> <p class="lead">{{ $t('settings.abstract') }}</p>
<div class="card border-secondary p-5 mt-5" v-if="profile.user.Source === 'db'"> <div v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')">
<h2 class="display-7">{{ $t('settings.password.headline') }}</h2> <div class="card border-secondary p-5" v-if="profile.user.ApiToken">
<p class="lead">{{ $t('settings.password.abstract') }}</p> <h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
<hr class="my-4"> <p class="lead">{{ $t('settings.api.abstract') }}</p>
<hr class="my-4">
<div class="row"> <p>{{ $t('settings.api.active-description') }}</p>
<div class="col-6"> <div class="row">
<div class="form-group"> <div class="col-6">
<label class="form-label mt-4" for="oldpw">{{ $t('settings.password.current-label') }}</label> <div class="form-group">
<input id="oldpw" v-model="pwFormData.OldPassword" class="form-control" :class="{ 'is-invalid': pwFormData.Password && !pwFormData.OldPassword }" type="password"> <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> </div>
<div class="col-6"> <div class="row">
</div> <div class="col-12">
</div> <div class="form-group">
<div class="row"> <p class="form-label mt-4">{{ $t('settings.api.token-created-label') }} {{profile.user.ApiTokenCreated}}</p>
<div class="col-6"> </div>
<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="col-6"> <div class="row mt-5">
<div class="form-group"> <div class="col-6">
<label class="form-label mt-4" for="confirmnewpw">{{ $t('settings.password.new-confirm-label') }}</label> <button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching">
<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"> <i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }}
<div class="invalid-feedback" v-if="pwFormData.PasswordRepeat !== ''&& pwFormData.Password !== pwFormData.PasswordRepeat">{{ $t('settings.password.invalid-confirm-label') }}</div> </button>
</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="row mt-5"> <div class="card border-secondary p-5" v-else>
<div class="col-6"> <h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="updatePassword" :disabled="profile.isFetching || !passwordChangeAllowed"> <p class="lead">{{ $t('settings.api.abstract') }}</p>
<i class="fa-solid fa-floppy-disk"></i> {{ $t('settings.password.change-button-text') }} <hr class="my-4">
</button> <p>{{ $t('settings.api.inactive-description') }}</p>
</div> <button class="btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching">
<div class="col-6"> <i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }}
</div> </button>
</div> </div>
</div> </div>
@@ -208,53 +173,4 @@ const updatePassword = async () => {
</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>

View File

@@ -1,78 +1,16 @@
<script setup> <script setup>
import {userStore} from "@/stores/users"; import {userStore} from "@/stores/users";
import {ref, onMounted, computed} from "vue"; import {ref,onMounted} from "vue";
import UserEditModal from "@/components/UserEditModal.vue"; import UserEditModal from "../components/UserEditModal.vue";
import UserViewModal from "@/components/UserViewModal.vue"; import UserViewModal from "../components/UserViewModal.vue";
import Pagination from "@/components/Pagination.vue";
import {useI18n} from "vue-i18n";
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;
@@ -107,15 +45,6 @@ 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>
@@ -132,7 +61,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.sources') }}</th> <th class="text-center" scope="col">{{ $t('users.table-heading.source') }}</th>
<th class="text-center" scope="col">{{ $t('users.table-heading.peers') }}</th> <th class="text-center" scope="col">{{ $t('users.table-heading.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 -->
@@ -151,7 +80,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><span class="badge bg-light me-1" v-for="src in user.AuthSources" :key="src">{{src}}</span></td> <td class="text-center"><span class="badge rounded-pill bg-light">{{user.Source}}</span></td>
<td class="text-center">{{user.PeerCount}}</td> <td class="text-center">{{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>
@@ -166,24 +95,28 @@ onMounted(() => {
</table> </table>
</div> </div>
<hr> <hr>
<div class="mt-3">
<div class="row"> <div class="row">
<div class="col-12 col-md-6"> <div class="col-6">
<Pagination <ul class="pagination pagination-sm">
:currentPage="users.currentPage" <li :class="{disabled:users.pageOffset===0}" class="page-item">
:totalCount="users.FilteredCount" <a class="page-link" @click="users.previousPage">&laquo;</a>
:pageSize="users.pageSize" </li>
:hasNextPage="users.hasNextPage"
:hasPrevPage="users.hasPrevPage" <li v-for="page in users.pages" :key="page" :class="{active:users.currentPage===page}" class="page-item">
:onGotoPage="users.gotoPage" <a class="page-link" @click="users.gotoPage(page)">{{page}}</a>
:onNextPage="users.nextPage" </li>
:onPrevPage="users.previousPage"
/> <li :class="{disabled:!users.hasNextPage}" class="page-item">
<a class="page-link" @click="users.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label> <label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="users.pageSize" class="form-select" @change="users.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="users.pageSize" class="form-select" @click="users.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
@@ -194,4 +127,5 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>

View File

@@ -5,13 +5,11 @@ import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: './',
plugins: [vue()], plugins: [vue()],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))
}, }
dedupe: ['prismjs']
}, },
build: { build: {
// //

114
go.mod
View File

@@ -1,19 +1,18 @@
module github.com/h44z/wg-portal module github.com/h44z/wg-portal
go 1.25.7 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.18.0 github.com/coreos/go-oidc/v3 v3.15.0
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-ldap/ldap/v3 v3.4.11
github.com/go-pkgz/routegroup v1.6.0 github.com/go-pkgz/routegroup v1.5.3
github.com/go-playground/validator/v10 v10.30.3 github.com/go-playground/validator/v10 v10.27.0
github.com/go-webauthn/webauthn v0.17.4 github.com/go-webauthn/webauthn v0.14.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus-community/pro-bing v0.8.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/swaggo/swag v1.16.6 github.com/swaggo/swag v1.16.6
@@ -22,90 +21,93 @@ 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.53.0 golang.org/x/crypto v0.42.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.31.0
golang.org/x/sys v0.46.0 golang.org/x/sys v0.36.0
golang.org/x/text v0.38.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlserver v1.6.3 gorm.io/driver/sqlserver v1.6.1
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.0
pgregory.net/rapid v1.3.0
) )
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // 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.2 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // 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.4 // indirect github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonpointer v0.22.0 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect github.com/go-openapi/jsonreference v0.21.1 // indirect
github.com/go-openapi/spec v0.22.4 // indirect github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect github.com/go-openapi/swag v0.24.1 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect github.com/go-openapi/swag/cmdutils v0.24.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect github.com/go-openapi/swag/conv v0.24.0 // indirect
github.com/go-openapi/swag/loading v0.26.0 // indirect github.com/go-openapi/swag/fileutils v0.24.0 // indirect
github.com/go-openapi/swag/stringutils v0.26.0 // indirect github.com/go-openapi/swag/jsonname v0.24.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect github.com/go-openapi/swag/jsonutils v0.24.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect github.com/go-openapi/swag/loading v0.24.0 // indirect
github.com/go-openapi/swag/mangling v0.24.0 // indirect
github.com/go-openapi/swag/netutils v0.24.0 // indirect
github.com/go-openapi/swag/stringutils v0.24.0 // indirect
github.com/go-openapi/swag/typeutils v0.24.0 // indirect
github.com/go-openapi/swag/yamlutils v0.24.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.10.0 // 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-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.1.25 // indirect
github.com/go-webauthn/x v0.2.6 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect
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.8 // indirect github.com/google/go-tpm v0.9.5 // 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.9.2 // indirect github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect github.com/mailru/easyjson v0.9.0 // indirect
github.com/mdlayher/genetlink v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/netlink v1.11.2 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/socket v0.6.0 // indirect github.com/mdlayher/netlink v1.8.0 // indirect
github.com/microsoft/go-mssqldb v1.10.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect
github.com/microsoft/go-mssqldb v1.9.3 // 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 v1.0.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.20.1 // indirect github.com/prometheus/procfs v0.17.0 // 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/tinylib/msgp v1.6.4 // indirect
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
golang.org/x/mod v0.36.0 // indirect golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.21.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/tools v0.45.0 // indirect golang.org/x/text v0.29.0 // indirect
golang.org/x/tools v0.36.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.8 // indirect
modernc.org/libc v1.72.3 // indirect modernc.org/libc v1.66.8 // 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.50.1 // indirect modernc.org/sqlite v1.38.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect
) )

283
go.sum
View File

@@ -1,45 +1,45 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/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.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= 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.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
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.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY= github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY=
github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU= github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 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.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -48,72 +48,73 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.2/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.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.10/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=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8=
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A=
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik=
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c=
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k=
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q=
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts=
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0=
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc=
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk=
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk=
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc=
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-openapi/swag/typeutils v0.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw=
github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4= github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI=
github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg= github.com/go-openapi/swag/yamlutils v0.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c=
github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8=
github.com/go-pkgz/routegroup v1.5.3 h1:IvH1KLcQkMap9jucQGBlef3IBloxSAe8USUFvxShFqs=
github.com/go-pkgz/routegroup v1.5.3/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.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= 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-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8= github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
@@ -121,10 +122,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.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
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=
@@ -132,19 +131,15 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.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=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -163,6 +158,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -176,50 +173,50 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU= github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM=
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18= github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHid9HK3594=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
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.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY= github.com/microsoft/go-mssqldb v1.9.3 h1:hy4p+LDC8LIGvI3JATnLVmBOLMJbmn5X400mr5j0lPs=
github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg= github.com/microsoft/go-mssqldb v1.9.3/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
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 v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc= github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0= github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_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.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
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=
@@ -236,8 +233,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA= github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
@@ -262,10 +257,10 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
@@ -278,16 +273,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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
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=
@@ -305,10 +302,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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.31.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=
@@ -316,8 +313,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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.17.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=
@@ -338,8 +335,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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.36.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=
@@ -368,23 +365,23 @@ 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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
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=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -399,40 +396,36 @@ 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.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI= gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY=
gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.3.28 h1:Vp156KUA2nPu9F1NEv036x9UGOjg2qsi5QlWTjZmtMk=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/fileutil v1.3.28/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.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.66.8 h1:/awsvTnyN/sNjvJm6S3lb7KZw5WV4ly/sBEG7ZUzmIE=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/libc v1.66.8/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.2.0/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.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

View File

@@ -23,6 +23,9 @@ 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"`
@@ -163,9 +166,6 @@ 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,15 +176,13 @@ 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, cfg *config.Config) (*SqlRepo, error) { func NewSqlRepository(db *gorm.DB) (*SqlRepo, error) {
repo := &SqlRepo{ repo := &SqlRepo{
db: db, db: db,
cfg: cfg,
} }
if err := repo.preCheck(); err != nil { if err := repo.preCheck(); err != nil {
@@ -222,8 +220,6 @@ 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{}))
@@ -232,114 +228,22 @@ func (r *SqlRepo) migrate() error {
slog.Debug("running migration: interface status", "result", r.db.AutoMigrate(&domain.InterfaceStatus{})) slog.Debug("running migration: interface status", "result", r.db.AutoMigrate(&domain.InterfaceStatus{}))
slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{})) slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{}))
var existingSysStat SysStat existingSysStat := SysStat{}
var err error 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{
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version MigratedAt: time.Now(),
if err != nil { SchemaVersion: SchemaVersion,
return err
} }
slog.Debug("sys-stat entry written", "schema_version", 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)
// Migration: 1 --> 2
if existingSysStat.SchemaVersion == 1 {
const schemaVersion = 2
// Preserve existing behavior for installations that had default-peer-creation enabled.
if r.cfg.DefaultPeerCreationEnabled() {
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)
}
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
}
}
// 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)
}
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
}
}
// Migration: 3 --> 4
if existingSysStat.SchemaVersion == 3 {
const schemaVersion = 4
cutoff := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
// Fix zero created_at timestamps for users. Set the to the last known update timestamp.
err := r.db.Model(&domain.User{}).Where("created_at < ?", cutoff).
Update("created_at", gorm.Expr("updated_at")).Error
if err != nil {
slog.Warn("failed to fix zero created_at for users", "error", err)
}
slog.Debug("fixed zero created_at timestamps for users", "schema_version", schemaVersion)
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
} }
slog.Debug("sys-stat entry written", "schema_version", SchemaVersion)
} }
return nil return nil
} }
func (r *SqlRepo) addMigration(schemaVersion uint64) (SysStat, error) {
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
}
if err := r.db.Create(&sysStat).Error; err != nil {
return SysStat{}, fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
return sysStat, nil
}
// region interfaces // region interfaces
// GetInterface returns the interface with the given id. // GetInterface returns the interface with the given id.
@@ -502,7 +406,7 @@ func (r *SqlRepo) getOrCreateInterface(
Identifier: id, Identifier: id,
} }
err := tx.Preload("Addresses").Attrs(interfaceDefaults).FirstOrCreate(&in, id).Error err := tx.Attrs(interfaceDefaults).FirstOrCreate(&in, id).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -711,7 +615,7 @@ func (r *SqlRepo) getOrCreatePeer(ui *domain.ContextUserInfo, tx *gorm.DB, id do
Identifier: id, Identifier: id,
} }
err := tx.Preload("Addresses").Attrs(interfaceDefaults).FirstOrCreate(&peer, id).Error err := tx.Attrs(interfaceDefaults).FirstOrCreate(&peer, id).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -844,7 +748,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").Preload("Authentications").First(&user, id).Error err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").First(&user, id).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound return nil, domain.ErrNotFound
@@ -862,8 +766,7 @@ 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 = ?", err := r.db.WithContext(ctx).Where("email = ?", email).Preload("WebAuthnCredentialList").Find(&users).Error
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
} }
@@ -903,7 +806,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").Preload("Authentications").Find(&users).Error err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Find(&users).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -923,7 +826,6 @@ 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
@@ -983,17 +885,7 @@ func (r *SqlRepo) getOrCreateUser(ui *domain.ContextUserInfo, tx *gorm.DB, id do
) { ) {
var user domain.User var user domain.User
result := tx.Model(&user).Preload("WebAuthnCredentialList").Preload("Authentications").Find(&user, id) // userDefaults will be applied to newly created user records
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(),
@@ -1002,15 +894,16 @@ 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.Create(&userDefaults).Error err := tx.Attrs(userDefaults).FirstOrCreate(&user, id).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &userDefaults, nil return &user, 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 {
@@ -1027,11 +920,6 @@ 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
} }

View File

@@ -1,168 +0,0 @@
package adapters
import (
"context"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
return db
}
func TestUpsertUser_SetsCreatedAtWhenZero(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ui := domain.SystemAdminContextUserInfo()
user := &domain.User{
Identifier: "test-user",
Email: "test@example.com",
// CreatedAt is zero
}
err := repo.upsertUser(ui, db, user)
require.NoError(t, err)
assert.False(t, user.CreatedAt.IsZero(), "CreatedAt should be set when it was zero")
assert.Equal(t, ui.UserId(), user.UpdatedBy, "UpdatedBy should be set when it was empty")
assert.WithinDuration(t, user.UpdatedAt, user.CreatedAt, time.Second,
"CreatedAt should be close to UpdatedAt for new user")
}
func TestUpsertUser_PreservesExistingCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ui := domain.SystemAdminContextUserInfo()
originalTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
user := &domain.User{
Identifier: "test-user",
Email: "test@example.com",
BaseModel: domain.BaseModel{
CreatedAt: originalTime,
CreatedBy: "original-creator",
},
}
err := repo.upsertUser(ui, db, user)
require.NoError(t, err)
assert.Equal(t, originalTime, user.CreatedAt, "CreatedAt should not be overwritten")
assert.Equal(t, "original-creator", user.CreatedBy, "CreatedBy should not be overwritten")
}
func TestSaveUser_NewUserGetsCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ctx := domain.SetUserInfo(context.Background(), domain.SystemAdminContextUserInfo())
before := time.Now().Add(-time.Second)
err := repo.SaveUser(ctx, "new-user", func(u *domain.User) (*domain.User, error) {
u.Email = "new@example.com"
return u, nil
})
require.NoError(t, err)
var saved domain.User
require.NoError(t, db.First(&saved, "identifier = ?", "new-user").Error)
assert.False(t, saved.CreatedAt.IsZero(), "CreatedAt should not be zero")
assert.True(t, saved.CreatedAt.After(before), "CreatedAt should be recent")
assert.NotEmpty(t, saved.CreatedBy, "CreatedBy should be set")
}
func TestMigration_FixesZeroCreatedAt(t *testing.T) {
db := newTestDB(t)
// Manually create tables and seed schema version 3
require.NoError(t, db.AutoMigrate(
&SysStat{},
&domain.User{},
&domain.UserAuthentication{},
&domain.Interface{},
&domain.Cidr{},
&domain.Peer{},
&domain.AuditEntry{},
&domain.UserWebauthnCredential{},
))
// Insert schema versions 1, 2, 3 so migration starts at 3
for v := uint64(1); v <= 3; v++ {
require.NoError(t, db.Create(&SysStat{SchemaVersion: v, MigratedAt: time.Now()}).Error)
}
updatedAt := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC)
// Insert a user with zero created_at but valid updated_at
require.NoError(t, db.Exec(
"INSERT INTO users (identifier, email, created_at, updated_at) VALUES (?, ?, ?, ?)",
"zero-user", "zero@example.com", time.Time{}, updatedAt,
).Error)
// Run migration
repo := &SqlRepo{db: db, cfg: &config.Config{}}
require.NoError(t, repo.migrate())
// Verify created_at was backfilled from updated_at
var user domain.User
require.NoError(t, db.First(&user, "identifier = ?", "zero-user").Error)
assert.Equal(t, updatedAt, user.CreatedAt, "created_at should be backfilled from updated_at")
// Verify schema version advanced to 4
var latest SysStat
require.NoError(t, db.Order("schema_version DESC").First(&latest).Error)
assert.Equal(t, uint64(4), latest.SchemaVersion)
}
func TestMigration_DoesNotTouchValidCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(
&SysStat{},
&domain.User{},
&domain.UserAuthentication{},
&domain.Interface{},
&domain.Cidr{},
&domain.Peer{},
&domain.AuditEntry{},
&domain.UserWebauthnCredential{},
))
for v := uint64(1); v <= 3; v++ {
require.NoError(t, db.Create(&SysStat{SchemaVersion: v, MigratedAt: time.Now()}).Error)
}
createdAt := time.Date(2024, 3, 1, 8, 0, 0, 0, time.UTC)
updatedAt := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC)
require.NoError(t, db.Exec(
"INSERT INTO users (identifier, email, created_at, updated_at) VALUES (?, ?, ?, ?)",
"valid-user", "valid@example.com", createdAt, updatedAt,
).Error)
repo := &SqlRepo{db: db, cfg: &config.Config{}}
require.NoError(t, repo.migrate())
var user domain.User
require.NoError(t, db.First(&user, "identifier = ?", "valid-user").Error)
assert.Equal(t, createdAt, user.CreatedAt, "valid created_at should not be modified")
}

View File

@@ -1,73 +0,0 @@
package adapters
import (
"context"
"reflect"
"testing"
"github.com/glebarez/sqlite"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func init() {
schema.RegisterSerializer("encstr", dummySerializer{})
}
type dummySerializer struct{}
func (dummySerializer) Scan(ctx context.Context, field *schema.Field, dst reflect.Value, dbValue any) error {
return nil
}
func (dummySerializer) Value(ctx context.Context, field *schema.Field, dst reflect.Value, fieldValue any) (any, error) {
if fieldValue == nil {
return nil, nil
}
if v, ok := fieldValue.(string); ok {
return v, nil
}
if v, ok := fieldValue.(domain.PreSharedKey); ok {
return string(v), nil
}
return fieldValue, nil
}
func TestSqlRepo_SaveInterface_Simple(t *testing.T) {
// Initialize in-memory database
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
// Migrate only what's needed for this test (avoids Peer and its encstr serializer)
require.NoError(t, db.AutoMigrate(&domain.Interface{}, &domain.Cidr{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ctx := domain.SetUserInfo(context.Background(), domain.SystemAdminContextUserInfo())
ifaceId := domain.InterfaceIdentifier("wg0")
// 1. Create an interface with one address
addr, _ := domain.CidrFromString("10.0.0.1/24")
initialIface := &domain.Interface{
Identifier: ifaceId,
Addresses: []domain.Cidr{addr},
}
require.NoError(t, db.Create(initialIface).Error)
// 2. Perform a "partial" update using SaveInterface (this is the buggy path)
err = repo.SaveInterface(ctx, ifaceId, func(in *domain.Interface) (*domain.Interface, error) {
in.DisplayName = "New Name"
return in, nil
})
require.NoError(t, err)
// 3. Verify that the address was NOT deleted
var finalIface domain.Interface
require.NoError(t, db.Preload("Addresses").First(&finalIface, "identifier = ?", ifaceId).Error)
require.Equal(t, "New Name", finalIface.DisplayName)
require.Len(t, finalIface.Addresses, 1, "Address list should still have 1 entry!")
require.Equal(t, "10.0.0.1/24", finalIface.Addresses[0].Cidr)
}

View File

@@ -30,7 +30,7 @@ type MetricsServer struct {
// Wireguard metrics labels // Wireguard metrics labels
var ( var (
ifaceLabels = []string{"interface"} ifaceLabels = []string{"interface"}
peerLabels = []string{"interface", "addresses", "id", "name", "user"} peerLabels = []string{"interface", "addresses", "id", "name"}
) )
// NewMetricsServer returns a new prometheus server // NewMetricsServer returns a new prometheus server
@@ -126,7 +126,6 @@ func (m *MetricsServer) UpdatePeerMetrics(peer *domain.Peer, status domain.PeerS
peer.Interface.AddressStr(), peer.Interface.AddressStr(),
string(status.PeerId), string(status.PeerId),
peer.DisplayName, peer.DisplayName,
string(peer.UserIdentifier),
} }
if status.LastHandshake != nil { if status.LastHandshake != nil {

View File

@@ -9,7 +9,6 @@ import (
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
"slices"
"strings" "strings"
"time" "time"
@@ -85,8 +84,8 @@ func NewLocalController(cfg *config.Config) (*LocalController, error) {
wg: wg, wg: wg,
nl: nl, nl: nl,
shellCmd: "bash", // we only support bash at the moment shellCmd: "bash", // we only support bash at the moment
resolvConfIfacePrefix: cfg.Backend.LocalResolvconfPrefix, // WireGuard interfaces have a tun. prefix in resolvconf resolvConfIfacePrefix: "tun.", // WireGuard interfaces have a tun. prefix in resolvconf
} }
return repo, nil return repo, nil
@@ -547,11 +546,7 @@ func (c LocalController) deletePeer(deviceId domain.InterfaceIdentifier, id doma
// region wg-quick-related // region wg-quick-related
func (c LocalController) ExecuteInterfaceHook( func (c LocalController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error {
_ context.Context,
id domain.InterfaceIdentifier,
hookCmd string,
) error {
if hookCmd == "" { if hookCmd == "" {
return nil return nil
} }
@@ -565,7 +560,7 @@ func (c LocalController) ExecuteInterfaceHook(
return nil return nil
} }
func (c LocalController) SetDNS(_ context.Context, id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error { func (c LocalController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error {
if dnsStr == "" && dnsSearchStr == "" { if dnsStr == "" && dnsSearchStr == "" {
return nil return nil
} }
@@ -594,7 +589,7 @@ func (c LocalController) SetDNS(_ context.Context, id domain.InterfaceIdentifier
return nil return nil
} }
func (c LocalController) UnsetDNS(_ context.Context, id domain.InterfaceIdentifier, _, _ string) error { func (c LocalController) UnsetDNS(id domain.InterfaceIdentifier) error {
dnsCommand := "resolvconf -d %resPref%i -f" dnsCommand := "resolvconf -d %resPref%i -f"
err := c.exec(dnsCommand, id) err := c.exec(dnsCommand, id)
@@ -616,7 +611,7 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
if len(stdin) > 0 { if len(stdin) > 0 {
b := &bytes.Buffer{} b := &bytes.Buffer{}
for _, ln := range stdin { for _, ln := range stdin {
if _, err := fmt.Fprint(b, ln+"\n"); err != nil { if _, err := fmt.Fprint(b, ln); err != nil {
return err return err
} }
} }
@@ -624,9 +619,7 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
} }
out, err := cmd.CombinedOutput() // execute and wait for output out, err := cmd.CombinedOutput() // execute and wait for output
if err != nil { if err != nil {
slog.Warn("failed to executed shell command", return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err)
"command", commandWithInterfaceName, "stdin", stdin, "output", string(out), "error", err)
return fmt.Errorf("failed to execute shell command %s: %w", commandWithInterfaceName, err)
} }
slog.Debug("executed shell command", slog.Debug("executed shell command",
"command", commandWithInterfaceName, "command", commandWithInterfaceName,
@@ -638,116 +631,49 @@ func (c LocalController) exec(command string, interfaceId domain.InterfaceIdenti
// region routing-related // region routing-related
// SetRoutes sets the routes for the given interface. If no routes are provided, the function is a no-op. func (c LocalController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error {
func (c LocalController) SetRoutes(_ context.Context, info domain.RoutingTableInfo) error { // update fwmark rules
interfaceId := info.Interface.Identifier if err := c.setFwMarkRules(rules); err != nil {
slog.Debug("setting linux routes", "interface", interfaceId, "table", info.Table, "fwMark", info.FwMark, return err
"cidrs", info.AllowedIps)
link, err := c.nl.LinkByName(string(interfaceId))
if err != nil {
return fmt.Errorf("failed to find physical link for %s: %w", interfaceId, err)
} }
cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps) // update main rule
realTable, realFwMark, err := c.getOrCreateRoutingTableAndFwMark(link, info.Table, info.FwMark) if err := c.setMainRule(rules); err != nil {
if err != nil { return err
return fmt.Errorf("failed to get or create routing table and fwmark for %s: %w", interfaceId, err)
}
wgDev, err := c.wg.Device(string(interfaceId))
if err != nil {
return fmt.Errorf("failed to get wg device for %s: %w", interfaceId, err)
}
currentFwMark := wgDev.FirewallMark
if int(realFwMark) != currentFwMark {
slog.Debug("updating fwmark for interface", "interface", interfaceId, "oldFwMark", currentFwMark,
"newFwMark", realFwMark, "oldTable", info.Table, "newTable", realTable)
if err := c.updateFwMarkOnInterface(interfaceId, int(realFwMark)); err != nil {
return fmt.Errorf("failed to update fwmark for interface %s to %d: %w", interfaceId, realFwMark, err)
}
} }
if err := c.setRoutesForFamily(interfaceId, link, netlink.FAMILY_V4, realTable, realFwMark, cidrsV4); err != nil { // cleanup old main rules
return fmt.Errorf("failed to set v4 routes: %w", err) if err := c.cleanupMainRule(rules); err != nil {
} return err
if err := c.setRoutesForFamily(interfaceId, link, netlink.FAMILY_V6, realTable, realFwMark, cidrsV6); err != nil {
return fmt.Errorf("failed to set v6 routes: %w", err)
} }
return nil return nil
} }
func (c LocalController) setRoutesForFamily( func (c LocalController) setFwMarkRules(rules []domain.RouteRule) error {
interfaceId domain.InterfaceIdentifier, for _, rule := range rules {
link netlink.Link, existingRules, err := c.nl.RuleList(int(rule.IpFamily))
family int,
table int,
fwMark uint32,
cidrs []domain.Cidr,
) error {
// first create or update the routes
for _, cidr := range cidrs {
err := c.nl.RouteReplace(&netlink.Route{
LinkIndex: link.Attrs().Index,
Dst: cidr.IpNet(),
Table: table,
Scope: unix.RT_SCOPE_LINK,
Type: unix.RTN_UNICAST,
})
if err != nil { if err != nil {
return fmt.Errorf("failed to add/update route %s on table %d for interface %s: %w", return fmt.Errorf("failed to get existing rules for family %s: %w", rule.IpFamily, err)
cidr.String(), table, interfaceId, err)
} }
}
// next remove old routes ruleExists := false
rawRoutes, err := c.nl.RouteListFiltered(family, &netlink.Route{ for _, existingRule := range existingRules {
LinkIndex: link.Attrs().Index, if rule.FwMark == existingRule.Mark && rule.Table == existingRule.Table {
Table: unix.RT_TABLE_UNSPEC, // all tables ruleExists = true
Scope: unix.RT_SCOPE_LINK, break
Type: unix.RTN_UNICAST,
}, netlink.RT_FILTER_TABLE|netlink.RT_FILTER_TYPE|netlink.RT_FILTER_OIF)
if err != nil {
return fmt.Errorf("failed to fetch raw routes for interface %s and family-id %d: %w",
interfaceId, family, err)
}
for _, rawRoute := range rawRoutes {
if rawRoute.Dst == nil { // handle default route
var netlinkAddr domain.Cidr
if family == netlink.FAMILY_V4 {
netlinkAddr, _ = domain.CidrFromString("0.0.0.0/0")
} else {
netlinkAddr, _ = domain.CidrFromString("::/0")
} }
rawRoute.Dst = netlinkAddr.IpNet()
} }
route := domain.CidrFromIpNet(*rawRoute.Dst) if ruleExists {
if slices.Contains(cidrs, route) { continue // rule already exists, no need to recreate it
continue
} }
if err := c.nl.RouteDel(&rawRoute); err != nil { // create a missing rule
return fmt.Errorf("failed to remove deprecated route %s from interface %s: %w", route, interfaceId, err)
}
}
// next, update route rules for normal routes
if table == 0 {
return nil // no need to update route rules as we are using the default table
}
existingRules, err := c.nl.RuleList(family)
if err != nil {
return fmt.Errorf("failed to get existing rules for family-id %d: %w", family, err)
}
ruleExists := slices.ContainsFunc(existingRules, func(rule netlink.Rule) bool {
return rule.Mark == fwMark && rule.Table == table
})
if !ruleExists {
if err := c.nl.RuleAdd(&netlink.Rule{ if err := c.nl.RuleAdd(&netlink.Rule{
Family: family, Family: int(rule.IpFamily),
Table: table, Table: rule.Table,
Mark: fwMark, Mark: rule.FwMark,
Invert: true, Invert: true,
SuppressIfgroup: -1, SuppressIfgroup: -1,
SuppressPrefixlen: -1, SuppressPrefixlen: -1,
@@ -756,102 +682,15 @@ func (c LocalController) setRoutesForFamily(
Goto: -1, Goto: -1,
Flow: -1, Flow: -1,
}); err != nil { }); err != nil {
return fmt.Errorf("failed to setup rule for fwmark %d and table %d for family-id %d: %w", return fmt.Errorf("failed to setup %s rule for fwmark %d and table %d: %w",
fwMark, table, family, err) rule.IpFamily, rule.FwMark, rule.Table, err)
} }
} }
mainRuleExists := slices.ContainsFunc(existingRules, func(rule netlink.Rule) bool {
return rule.SuppressPrefixlen == 0 && rule.Table == unix.RT_TABLE_MAIN
})
if !mainRuleExists && domain.ContainsDefaultRoute(cidrs) {
err = c.nl.RuleAdd(&netlink.Rule{
Family: family,
Table: unix.RT_TABLE_MAIN,
SuppressIfgroup: -1,
SuppressPrefixlen: 0,
Priority: c.getMainRulePriority(existingRules),
Mark: 0,
Mask: nil,
Goto: -1,
Flow: -1,
})
}
// finally, clean up extra main rules - only one rule is allowed
existingRules, err = c.nl.RuleList(family)
if err != nil {
return fmt.Errorf("failed to get existing main rules for family-id %d: %w", family, err)
}
mainRuleCount := 0
for _, rule := range existingRules {
if rule.SuppressPrefixlen == 0 && rule.Table == unix.RT_TABLE_MAIN {
mainRuleCount++
}
if mainRuleCount > 1 {
if err := c.nl.RuleDel(&rule); err != nil {
return fmt.Errorf("failed to remove extra main rule for family-id %d: %w", family, err)
}
}
}
return nil return nil
} }
func (c LocalController) getOrCreateRoutingTableAndFwMark(
link netlink.Link,
tableIn int,
fwMarkIn uint32,
) (
table int,
fwmark uint32,
err error,
) {
table = tableIn
fwmark = fwMarkIn
if fwmark == 0 {
// generate a new (temporary) firewall mark based on the interface index
fwmark = uint32(c.cfg.Advanced.RouteTableOffset + link.Attrs().Index)
}
if table == 0 {
table = int(fwmark) // generate a new routing table base on interface index
}
return
}
func (c LocalController) updateFwMarkOnInterface(interfaceId domain.InterfaceIdentifier, fwMark int) error {
// apply the new fwmark to the wireguard interface
err := c.wg.ConfigureDevice(string(interfaceId), wgtypes.Config{
FirewallMark: &fwMark,
})
if err != nil {
return fmt.Errorf("failed to update fwmark of interface %s to: %d: %w", interfaceId, fwMark, err)
}
return nil
}
func (c LocalController) getMainRulePriority(existingRules []netlink.Rule) int {
prio := c.cfg.Advanced.RulePrioOffset
for {
isFresh := true
for _, existingRule := range existingRules {
if existingRule.Priority == prio {
isFresh = false
break
}
}
if isFresh {
break
} else {
prio++
}
}
return prio
}
func (c LocalController) getRulePriority(existingRules []netlink.Rule) int { func (c LocalController) getRulePriority(existingRules []netlink.Rule) int {
prio := 32700 // linux main rule has a prio of 32766 prio := 32700 // linux main rule has a priority of 32766
for { for {
isFresh := true isFresh := true
for _, existingRule := range existingRules { for _, existingRule := range existingRules {
@@ -869,145 +708,126 @@ func (c LocalController) getRulePriority(existingRules []netlink.Rule) int {
return prio return prio
} }
// RemoveRoutes removes the routes for the given interface. If no routes are provided, the function is a no-op. func (c LocalController) setMainRule(rules []domain.RouteRule) error {
func (c LocalController) RemoveRoutes(_ context.Context, info domain.RoutingTableInfo) error { var family domain.IpFamily
interfaceId := info.Interface.Identifier shouldHaveMainRule := false
slog.Debug("removing linux routes", "interface", interfaceId, "table", info.Table, "fwMark", info.FwMark, for _, rule := range rules {
"cidrs", info.AllowedIps) family = rule.IpFamily
if rule.HasDefault == true {
wgDev, err := c.wg.Device(string(interfaceId)) shouldHaveMainRule = true
if err != nil { break
slog.Debug("wg device already removed, route cleanup might be incomplete", "interface", interfaceId)
wgDev = nil
}
link, err := c.nl.LinkByName(string(interfaceId))
if err != nil {
slog.Debug("physical link already removed, route cleanup might be incomplete", "interface", interfaceId)
link = nil
}
fwMark := info.FwMark
if wgDev != nil && info.FwMark == 0 {
fwMark = uint32(wgDev.FirewallMark)
}
table := info.Table
if wgDev != nil && info.Table == 0 {
table = wgDev.FirewallMark // use the fwMark as table, this is the default behavior
}
linkIndex := -1
if link != nil {
linkIndex = link.Attrs().Index
}
cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps)
realTable, realFwMark, err := c.getOrCreateRoutingTableAndFwMark(link, table, fwMark)
if err != nil {
return fmt.Errorf("failed to get or create routing table and fwmark for %s: %w", interfaceId, err)
}
if linkIndex > 0 {
err = c.removeRoutesForFamily(interfaceId, link, netlink.FAMILY_V4, realTable, realFwMark, cidrsV4)
if err != nil {
return fmt.Errorf("failed to remove v4 routes: %w", err)
} }
err = c.removeRoutesForFamily(interfaceId, link, netlink.FAMILY_V6, realTable, realFwMark, cidrsV6) }
if err != nil { if !shouldHaveMainRule {
return fmt.Errorf("failed to remove v6 routes: %w", err) return nil
}
existingRules, err := c.nl.RuleList(int(family))
if err != nil {
return fmt.Errorf("failed to get existing rules for family %s: %w", family, err)
}
ruleExists := false
for _, existingRule := range existingRules {
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
ruleExists = true
break
} }
} }
if table > 0 { if ruleExists {
err = c.removeRouteRulesForTable(netlink.FAMILY_V4, realTable) return nil // rule already exists, skip re-creation
if err != nil { }
return fmt.Errorf("failed to remove v4 route rules for %s: %w", interfaceId, err)
if err := c.nl.RuleAdd(&netlink.Rule{
Family: int(family),
Table: unix.RT_TABLE_MAIN,
SuppressIfgroup: -1,
SuppressPrefixlen: 0,
Priority: c.getMainRulePriority(existingRules),
Mark: 0,
Mask: nil,
Goto: -1,
Flow: -1,
}); err != nil {
return fmt.Errorf("failed to setup rule for main table: %w", err)
}
return nil
}
func (c LocalController) getMainRulePriority(existingRules []netlink.Rule) int {
priority := c.cfg.Advanced.RulePrioOffset
for {
isFresh := true
for _, existingRule := range existingRules {
if existingRule.Priority == priority {
isFresh = false
break
}
} }
err = c.removeRouteRulesForTable(netlink.FAMILY_V6, realTable) if isFresh {
if err != nil { break
return fmt.Errorf("failed to remove v6 route rules for %s: %w", interfaceId, err) } else {
priority++
}
}
return priority
}
func (c LocalController) cleanupMainRule(rules []domain.RouteRule) error {
var family domain.IpFamily
for _, rule := range rules {
family = rule.IpFamily
break
}
existingRules, err := c.nl.RuleList(int(family))
if err != nil {
return fmt.Errorf("failed to get existing rules for family %s: %w", family, err)
}
shouldHaveMainRule := false
for _, rule := range rules {
if rule.HasDefault == true {
shouldHaveMainRule = true
break
}
}
mainRules := 0
for _, existingRule := range existingRules {
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
mainRules++
}
}
removalCount := 0
if mainRules > 1 {
removalCount = mainRules - 1 // we only want one single rule
}
if !shouldHaveMainRule {
removalCount = mainRules
}
for _, existingRule := range existingRules {
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
if removalCount > 0 {
existingRule.Family = int(family) // set family, somehow the RuleList method does not populate the family field
if err := c.nl.RuleDel(&existingRule); err != nil {
return fmt.Errorf("failed to delete main rule: %w", err)
}
removalCount--
}
} }
} }
return nil return nil
} }
func (c LocalController) removeRoutesForFamily( func (c LocalController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error {
interfaceId domain.InterfaceIdentifier, // TODO implement me
link netlink.Link, panic("implement me")
family int,
table int,
fwMark uint32,
cidrs []domain.Cidr,
) error {
// first remove all rules
existingRules, err := c.nl.RuleList(family)
if err != nil {
return fmt.Errorf("failed to get existing rules for family %d: %w", family, err)
}
for _, existingRule := range existingRules {
if fwMark == existingRule.Mark && table == existingRule.Table {
existingRule.Family = family // set family, somehow the RuleList method does not populate the family field
if err := c.nl.RuleDel(&existingRule); err != nil {
return fmt.Errorf("failed to delete old fwmark rule: %w", err)
}
}
}
// next remove all routes
rawRoutes, err := c.nl.RouteListFiltered(family, &netlink.Route{
LinkIndex: link.Attrs().Index,
Table: unix.RT_TABLE_UNSPEC, // all tables
Scope: unix.RT_SCOPE_LINK,
Type: unix.RTN_UNICAST,
}, netlink.RT_FILTER_TABLE|netlink.RT_FILTER_TYPE|netlink.RT_FILTER_OIF)
if err != nil {
return fmt.Errorf("failed to fetch raw routes for interface %s and family-id %d: %w",
interfaceId, family, err)
}
for _, rawRoute := range rawRoutes {
if rawRoute.Dst == nil { // handle default route
var netlinkAddr domain.Cidr
if family == netlink.FAMILY_V4 {
netlinkAddr, _ = domain.CidrFromString("0.0.0.0/0")
} else {
netlinkAddr, _ = domain.CidrFromString("::/0")
}
rawRoute.Dst = netlinkAddr.IpNet()
}
if rawRoute.Table != table {
continue // ignore routes from other tables
}
route := domain.CidrFromIpNet(*rawRoute.Dst)
if !slices.Contains(cidrs, route) {
continue // only remove routes that were previously added
}
if err := c.nl.RouteDel(&rawRoute); err != nil {
return fmt.Errorf("failed to remove old route %s from interface %s: %w", route, interfaceId, err)
}
}
return nil
}
func (c LocalController) removeRouteRulesForTable(
family int,
table int,
) error {
existingRules, err := c.nl.RuleList(family)
if err != nil {
return fmt.Errorf("failed to get existing route rules for family-id %d: %w", family, err)
}
for _, existingRule := range existingRules {
if existingRule.Table == table {
err := c.nl.RuleDel(&existingRule)
if err != nil {
return fmt.Errorf("failed to delete old rule for table %d and family-id %d: %w", table, family, err)
}
}
}
return nil
} }
// endregion routing-related // endregion routing-related

View File

@@ -15,9 +15,6 @@ import (
"github.com/h44z/wg-portal/internal/lowlevel" "github.com/h44z/wg-portal/internal/lowlevel"
) )
const MikrotikRouteDistance = 5
const MikrotikDefaultRoutingTable = "main"
type MikrotikController struct { type MikrotikController struct {
coreCfg *config.Config coreCfg *config.Config
cfg *config.BackendMikrotik cfg *config.BackendMikrotik
@@ -25,9 +22,8 @@ type MikrotikController struct {
client *lowlevel.MikrotikApiClient client *lowlevel.MikrotikApiClient
// Add mutexes to prevent race conditions // Add mutexes to prevent race conditions
interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*sync.Mutex interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*sync.Mutex
peerMutexes sync.Map // map[domain.PeerIdentifier]*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 NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikController, error) { func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikController, error) {
@@ -44,7 +40,6 @@ func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik)
interfaceMutexes: sync.Map{}, interfaceMutexes: sync.Map{},
peerMutexes: sync.Map{}, peerMutexes: sync.Map{},
coreMutex: sync.Mutex{},
}, nil }, nil
} }
@@ -768,404 +763,33 @@ func (c *MikrotikController) DeletePeer(
// region wg-quick-related // region wg-quick-related
func (c *MikrotikController) ExecuteInterfaceHook( func (c *MikrotikController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error {
_ context.Context,
_ domain.InterfaceIdentifier,
_ string,
) error {
// TODO implement me // TODO implement me
slog.Error("interface hooks are not yet supported for Mikrotik backends, please open an issue on GitHub") panic("implement me")
return nil
} }
func (c *MikrotikController) SetDNS( func (c *MikrotikController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error {
ctx context.Context, // TODO implement me
_ domain.InterfaceIdentifier, panic("implement me")
dnsStr, _ string,
) error {
// Lock the interface to prevent concurrent modifications
c.coreMutex.Lock()
defer c.coreMutex.Unlock()
// check if the server is already configured
wgReply := c.client.Get(ctx, "/ip/dns", &lowlevel.MikrotikRequestOptions{
PropList: []string{"servers"},
})
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("unable to find WireGuard dns settings: %v", wgReply.Error)
}
var existingServers []string
existingServers = append(existingServers, strings.Split(wgReply.Data.GetString("servers"), ",")...)
newServers := strings.Split(dnsStr, ",")
mergedServers := slices.Clone(existingServers)
for _, s := range newServers {
if s == "" {
continue
}
if !slices.Contains(mergedServers, s) {
mergedServers = append(mergedServers, s)
}
}
mergedServersStr := strings.Join(mergedServers, ",")
reply := c.client.ExecList(ctx, "/ip/dns/set", lowlevel.GenericJsonObject{
"servers": mergedServersStr,
})
if reply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("failed to set DNS servers: %s: %v", mergedServersStr, reply.Error)
}
return nil
} }
func (c *MikrotikController) UnsetDNS( func (c *MikrotikController) UnsetDNS(id domain.InterfaceIdentifier) error {
ctx context.Context, // TODO implement me
_ domain.InterfaceIdentifier, panic("implement me")
dnsStr, _ string,
) error {
// Lock the interface to prevent concurrent modifications
c.coreMutex.Lock()
defer c.coreMutex.Unlock()
// retrieve current DNS settings
wgReply := c.client.Get(ctx, "/ip/dns", &lowlevel.MikrotikRequestOptions{
PropList: []string{"servers"},
})
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("unable to find WireGuard dns settings: %v", wgReply.Error)
}
var existingServers []string
existingServers = append(existingServers, strings.Split(wgReply.Data.GetString("servers"), ",")...)
oldServers := strings.Split(dnsStr, ",")
mergedServers := make([]string, 0, len(existingServers))
for _, s := range existingServers {
if s == "" {
continue
}
if !slices.Contains(oldServers, s) {
mergedServers = append(mergedServers, s) // only keep the servers that are not in the old list
}
}
mergedServersStr := strings.Join(mergedServers, ",")
reply := c.client.ExecList(ctx, "/ip/dns/set", lowlevel.GenericJsonObject{
"servers": mergedServersStr,
})
if reply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("failed to set DNS servers: %s: %v", mergedServersStr, reply.Error)
}
return nil
} }
// endregion wg-quick-related // endregion wg-quick-related
// region routing-related // region routing-related
// SetRoutes sets the routes for the given interface. If no routes are provided, the function is a no-op. func (c *MikrotikController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error {
func (c *MikrotikController) SetRoutes(ctx context.Context, info domain.RoutingTableInfo) error { // TODO implement me
interfaceId := info.Interface.Identifier panic("implement me")
slog.Debug("setting mikrotik routes", "interface", interfaceId, "table", info.TableStr, "cidrs", info.AllowedIps)
// Mikrotik needs some time to apply the changes.
// If we don't wait, the routes might get created multiple times as the dynamic routes are not yet available.
time.Sleep(2 * time.Second)
tableName, err := c.getOrCreateRoutingTables(ctx, info.Interface.Identifier, info.TableStr)
if err != nil {
return fmt.Errorf("failed to get or create routing table for %s: %v", interfaceId, err)
}
cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps)
err = c.setRoutesForFamily(ctx, interfaceId, false, tableName, cidrsV4)
if err != nil {
return fmt.Errorf("failed to set IPv4 routes for %s: %v", interfaceId, err)
}
err = c.setRoutesForFamily(ctx, interfaceId, true, tableName, cidrsV6)
if err != nil {
return fmt.Errorf("failed to set IPv6 routes for %s: %v", interfaceId, err)
}
return nil
} }
func (c *MikrotikController) resolveRouteTableName(name string) string { func (c *MikrotikController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error {
name = strings.TrimSpace(name) // TODO implement me
panic("implement me")
var mikrotikTableName string
switch strings.ToLower(name) {
case "", "0":
mikrotikTableName = MikrotikDefaultRoutingTable
case MikrotikDefaultRoutingTable:
return fmt.Sprintf("wgportal-%s",
MikrotikDefaultRoutingTable) // if the Mikrotik Main table should be used, the table-name should be left empty or set to "0".
default:
mikrotikTableName = name
}
return mikrotikTableName
}
func (c *MikrotikController) getOrCreateRoutingTables(
ctx context.Context,
interfaceId domain.InterfaceIdentifier,
table string,
) (string, error) {
// retrieve current routing tables
wgReply := c.client.Query(ctx, "/routing/table", &lowlevel.MikrotikRequestOptions{
PropList: []string{
".id", "dynamic", "fib", "name",
},
})
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
return "", fmt.Errorf("unable to query routing tables: %v", wgReply.Error)
}
wantedTableName := c.resolveRouteTableName(table)
// check if the table already exists
for _, table := range wgReply.Data {
if table.GetString("name") == wantedTableName {
return wantedTableName, nil // already exists, nothing to do
}
}
// create the table if it does not exist
createReply := c.client.Create(ctx, "/routing/table", lowlevel.GenericJsonObject{
"name": wantedTableName,
"comment": fmt.Sprintf("Routing Table for %s", interfaceId),
"fib": strconv.FormatBool(true),
})
if createReply.Status != lowlevel.MikrotikApiStatusOk {
return "", fmt.Errorf("failed to create routing table %s: %v", wantedTableName, createReply.Error)
}
return wantedTableName, nil
}
func (c *MikrotikController) setRoutesForFamily(
ctx context.Context,
interfaceId domain.InterfaceIdentifier,
ipV6 bool,
table string,
cidrs []domain.Cidr,
) error {
apiPath := "/ip/route"
if ipV6 {
apiPath = "/ipv6/route"
}
// retrieve current routes
wgReply := c.client.Query(ctx, apiPath, &lowlevel.MikrotikRequestOptions{
PropList: []string{
".id", "disabled", "inactive", "distance", "dst-address", "dynamic", "gateway", "immediate-gw",
"routing-table", "scope", "target-scope", "client-dns", "comment", "disabled", "responder",
},
Filters: map[string]string{
"gateway": string(interfaceId),
},
})
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("unable to find WireGuard IP route settings (v6=%t): %v", ipV6, wgReply.Error)
}
// first create or update the routes
for _, cidr := range cidrs {
// check if the route already exists
exists := false
for _, route := range wgReply.Data {
existingRoute, err := domain.CidrFromString(route.GetString("dst-address"))
if err != nil {
slog.Warn("failed to parse route destination address",
"cidr", route.GetString("dst-address"), "error", err)
continue
}
if existingRoute.EqualPrefix(cidr) && route.GetString("routing-table") == table {
exists = true
break
}
}
if exists {
continue // route already exists, nothing to do
}
// create the route
reply := c.client.Create(ctx, apiPath, lowlevel.GenericJsonObject{
"gateway": string(interfaceId),
"dst-address": cidr.String(),
"distance": strconv.Itoa(MikrotikRouteDistance),
"disabled": strconv.FormatBool(false),
"routing-table": table,
})
if reply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("failed to create new route %s via %s: %v", cidr.String(), interfaceId, reply.Error)
}
}
// finally, remove the routes that are not in the new list
for _, route := range wgReply.Data {
if route.GetBool("dynamic") {
continue // dynamic routes are not managed by the controller, nothing to do
}
existingRoute, err := domain.CidrFromString(route.GetString("dst-address"))
if err != nil {
slog.Warn("failed to parse route destination address",
"cidr", route.GetString("dst-address"), "error", err)
continue
}
valid := false
for _, cidr := range cidrs {
if existingRoute.EqualPrefix(cidr) {
valid = true
break
}
}
if valid {
continue // route is still valid, nothing to do
}
// remove the route
reply := c.client.Delete(ctx, apiPath+"/"+route.GetString(".id"))
if reply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("failed to remove outdated route %s: %v", existingRoute.String(), reply.Error)
}
}
return nil
}
// RemoveRoutes removes the routes for the given interface. If no routes are provided, the function is a no-op.
func (c *MikrotikController) RemoveRoutes(ctx context.Context, info domain.RoutingTableInfo) error {
interfaceId := info.Interface.Identifier
slog.Debug("removing mikrotik routes", "interface", interfaceId, "table", info.TableStr, "cidrs", info.AllowedIps)
tableName := c.resolveRouteTableName(info.TableStr)
cidrsV4, cidrsV6 := domain.CidrsPerFamily(info.AllowedIps)
err := c.removeRoutesForFamily(ctx, interfaceId, false, tableName, cidrsV4)
if err != nil {
return fmt.Errorf("failed to remove IPv4 routes for %s: %v", interfaceId, err)
}
err = c.removeRoutesForFamily(ctx, interfaceId, true, tableName, cidrsV6)
if err != nil {
return fmt.Errorf("failed to remove IPv6 routes for %s: %v", interfaceId, err)
}
err = c.removeRoutingTable(ctx, tableName)
if err != nil {
return fmt.Errorf("failed to remove routing table for %s: %v", interfaceId, err)
}
return nil
}
func (c *MikrotikController) removeRoutesForFamily(
ctx context.Context,
interfaceId domain.InterfaceIdentifier,
ipV6 bool,
table string,
cidrs []domain.Cidr,
) error {
apiPath := "/ip/route"
if ipV6 {
apiPath = "/ipv6/route"
}
// retrieve current routes
wgReply := c.client.Query(ctx, apiPath, &lowlevel.MikrotikRequestOptions{
PropList: []string{
".id", "disabled", "inactive", "distance", "dst-address", "dynamic", "gateway", "immediate-gw",
"routing-table", "scope", "target-scope", "client-dns", "comment", "disabled", "responder",
},
Filters: map[string]string{
"gateway": string(interfaceId),
},
})
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("unable to find WireGuard IP route settings (v6=%t): %v", ipV6, wgReply.Error)
}
// remove the routes from the list
for _, route := range wgReply.Data {
if route.GetBool("dynamic") {
continue // dynamic routes are not managed by the controller, nothing to do
}
existingRoute, err := domain.CidrFromString(route.GetString("dst-address"))
if err != nil {
slog.Warn("failed to parse route destination address",
"cidr", route.GetString("dst-address"), "error", err)
continue
}
remove := false
for _, cidr := range cidrs {
if existingRoute.EqualPrefix(cidr) && route.GetString("routing-table") == table {
remove = true
break
}
}
if !remove {
continue // route is still valid, nothing to do
}
// remove the route
reply := c.client.Delete(ctx, apiPath+"/"+route.GetString(".id"))
if reply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("failed to remove old route %s: %v", existingRoute.String(), reply.Error)
}
}
return nil
}
func (c *MikrotikController) removeRoutingTable(
ctx context.Context,
table string,
) error {
if table == MikrotikDefaultRoutingTable {
return nil // we cannot remove the default table
}
// retrieve current routing tables
wgReply := c.client.Query(ctx, "/routing/table", &lowlevel.MikrotikRequestOptions{
PropList: []string{
".id", "dynamic", "fib", "name",
},
})
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("unable to query routing tables: %v", wgReply.Error)
}
for _, existingTable := range wgReply.Data {
if existingTable.GetBool("dynamic") {
continue // dynamic tables are not managed by the controller, nothing to do
}
if existingTable.GetString("name") != table {
continue // not the table we want to remove
}
// remove the table
reply := c.client.Delete(ctx, "/routing/table/"+existingTable.GetString(".id"))
if reply.Status != lowlevel.MikrotikApiStatusOk {
return fmt.Errorf("failed to remove routing table %s: %v", table, reply.Error)
}
return nil
}
return nil
} }
// endregion routing-related // endregion routing-related

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
package adapters
import (
"bytes"
"fmt"
"log/slog"
"os/exec"
"strings"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain"
)
// WgQuickRepo implements higher level wg-quick like interactions like setting DNS, routing tables or interface hooks.
type WgQuickRepo struct {
shellCmd string
resolvConfIfacePrefix string
}
// NewWgQuickRepo creates a new WgQuickRepo instance.
func NewWgQuickRepo() *WgQuickRepo {
return &WgQuickRepo{
shellCmd: "bash",
resolvConfIfacePrefix: "tun.",
}
}
// ExecuteInterfaceHook executes the given hook command.
// The hook command can contain the following placeholders:
//
// %i: the interface identifier.
func (r *WgQuickRepo) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error {
if hookCmd == "" {
return nil
}
slog.Debug("executing interface hook", "interface", id, "hook", hookCmd)
err := r.exec(hookCmd, id)
if err != nil {
return fmt.Errorf("failed to exec hook: %w", err)
}
return nil
}
// SetDNS sets the DNS settings for the given interface. It uses resolvconf to set the DNS settings.
func (r *WgQuickRepo) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error {
if dnsStr == "" && dnsSearchStr == "" {
return nil
}
dnsServers := internal.SliceString(dnsStr)
dnsSearchDomains := internal.SliceString(dnsSearchStr)
dnsCommand := "resolvconf -a %resPref%i -m 0 -x"
dnsCommandInput := make([]string, 0, len(dnsServers)+len(dnsSearchDomains))
for _, dnsServer := range dnsServers {
dnsCommandInput = append(dnsCommandInput, fmt.Sprintf("nameserver %s", dnsServer))
}
for _, searchDomain := range dnsSearchDomains {
dnsCommandInput = append(dnsCommandInput, fmt.Sprintf("search %s", searchDomain))
}
err := r.exec(dnsCommand, id, dnsCommandInput...)
if err != nil {
return fmt.Errorf(
"failed to set dns settings (is resolvconf available?, for systemd create this symlink: ln -s /usr/bin/resolvectl /usr/local/bin/resolvconf): %w",
err,
)
}
return nil
}
// UnsetDNS unsets the DNS settings for the given interface. It uses resolvconf to unset the DNS settings.
func (r *WgQuickRepo) UnsetDNS(id domain.InterfaceIdentifier) error {
dnsCommand := "resolvconf -d %resPref%i -f"
err := r.exec(dnsCommand, id)
if err != nil {
return fmt.Errorf("failed to unset dns settings: %w", err)
}
return nil
}
func (r *WgQuickRepo) replaceCommandPlaceHolders(command string, interfaceId domain.InterfaceIdentifier) string {
command = strings.ReplaceAll(command, "%resPref", r.resolvConfIfacePrefix)
return strings.ReplaceAll(command, "%i", string(interfaceId))
}
func (r *WgQuickRepo) exec(command string, interfaceId domain.InterfaceIdentifier, stdin ...string) error {
commandWithInterfaceName := r.replaceCommandPlaceHolders(command, interfaceId)
cmd := exec.Command(r.shellCmd, "-ce", commandWithInterfaceName)
if len(stdin) > 0 {
b := &bytes.Buffer{}
for _, ln := range stdin {
if _, err := fmt.Fprint(b, ln); err != nil {
return err
}
}
cmd.Stdin = b
}
out, err := cmd.CombinedOutput() // execute and wait for output
if err != nil {
return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err)
}
slog.Debug("executed shell command",
"command", commandWithInterfaceName,
"output", string(out))
return nil
}

View File

@@ -800,126 +800,6 @@
} }
} }
}, },
"/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": [
@@ -1444,206 +1324,6 @@
} }
} }
}, },
"/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": [
@@ -1870,38 +1550,6 @@
} }
} }
}, },
"/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": [
@@ -2057,23 +1705,6 @@
} }
} }
}, },
"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": {
@@ -2528,10 +2159,6 @@
} }
] ]
}, },
"UserDisplayName": {
"description": "the owner display name",
"type": "string"
},
"UserIdentifier": { "UserIdentifier": {
"description": "the owner", "description": "the owner",
"type": "string" "type": "string"
@@ -2676,12 +2303,6 @@
"ApiTokenCreated": { "ApiTokenCreated": {
"type": "string" "type": "string"
}, },
"AuthSources": {
"type": "array",
"items": {
"type": "string"
}
},
"Department": { "Department": {
"type": "string" "type": "string"
}, },
@@ -2725,11 +2346,14 @@
"PeerCount": { "PeerCount": {
"type": "integer" "type": "integer"
}, },
"PersistLocalChanges": {
"type": "boolean"
},
"Phone": { "Phone": {
"type": "string" "type": "string"
},
"ProviderName": {
"type": "string"
},
"Source": {
"type": "string"
} }
} }
}, },

View File

@@ -16,17 +16,6 @@ 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:
@@ -333,9 +322,6 @@ 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
@@ -431,10 +417,6 @@ 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:
@@ -465,10 +447,12 @@ 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:
@@ -1093,84 +1077,6 @@ 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
@@ -1536,27 +1442,6 @@ 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
@@ -1662,136 +1547,6 @@ 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

View File

@@ -17,6 +17,11 @@
"paths": { "paths": {
"/interface/all": { "/interface/all": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -47,16 +52,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/interface/by-id/{id}": { "/interface/by-id/{id}": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -105,14 +110,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"
@@ -177,14 +182,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
},
"delete": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
] ],
},
"delete": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -236,16 +241,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/interface/new": { "/interface/new": {
"post": { "post": {
"security": [
{
"BasicAuth": []
}
],
"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"
@@ -303,16 +308,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/interface/prepare": { "/interface/prepare": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"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"
@@ -347,16 +352,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/metrics/by-interface/{id}": { "/metrics/by-interface/{id}": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -405,16 +410,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/metrics/by-peer/{id}": { "/metrics/by-peer/{id}": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -463,16 +468,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/metrics/by-user/{id}": { "/metrics/by-user/{id}": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -521,16 +526,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/peer/by-id/{id}": { "/peer/by-id/{id}": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"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"
@@ -580,14 +585,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"
@@ -652,14 +657,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
},
"delete": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
] ],
},
"delete": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -711,16 +716,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/peer/by-interface/{id}": { "/peer/by-interface/{id}": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -760,16 +765,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/peer/by-user/{id}": { "/peer/by-user/{id}": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"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"
@@ -810,16 +815,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/peer/new": { "/peer/new": {
"post": { "post": {
"security": [
{
"BasicAuth": []
}
],
"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"
@@ -877,16 +882,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/peer/prepare/{id}": { "/peer/prepare/{id}": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"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"
@@ -942,16 +947,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/provisioning/data/peer-config": { "/provisioning/data/peer-config": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"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",
@@ -1008,16 +1013,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/provisioning/data/peer-qr": { "/provisioning/data/peer-qr": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"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",
@@ -1074,16 +1079,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/provisioning/data/user-info": { "/provisioning/data/user-info": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"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"
@@ -1144,16 +1149,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/provisioning/new-peer": { "/provisioning/new-peer": {
"post": { "post": {
"security": [
{
"BasicAuth": []
}
],
"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"
@@ -1211,16 +1216,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/user/all": { "/user/all": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -1251,16 +1256,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/user/by-id/{id}": { "/user/by-id/{id}": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"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"
@@ -1310,14 +1315,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"
@@ -1382,14 +1387,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
},
"delete": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
] ],
},
"delete": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -1441,16 +1446,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
}, },
"/user/new": { "/user/new": {
"post": { "post": {
"security": [
{
"BasicAuth": []
}
],
"description": "Only admins can create new records.", "description": "Only admins can create new records.",
"produces": [ "produces": [
"application/json" "application/json"
@@ -1508,12 +1513,7 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
}, }
"security": [
{
"BasicAuth": []
}
]
} }
} }
}, },
@@ -2132,22 +2132,6 @@
"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",
@@ -2221,6 +2205,20 @@
"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"
} }
} }
}, },

View File

@@ -490,18 +490,6 @@ 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
@@ -564,6 +552,17 @@ 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: 709 B

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -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="{{$.BasePath}}/css/bootstrap.min.css"> <link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="{{$.BasePath}}/fonts/fontawesome-all.min.css"> <link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="shortcut icon" type="image/x-icon" href="{{$.BasePath}}/app/favicon.ico"> <link rel="shortcut icon" type="image/x-icon" href="/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="{{$.BasePath}}/api/v0/doc.html" title="API version 0" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a> <a href="/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="{{$.BasePath}}/api/v1/doc.html" title="API version 1" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a> <a href="/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="{{$.BasePath}}/api/v2/doc.html" title="API version 2" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a> <a href="/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="{{$.BasePath}}/js/jquery.min.js"></script> <script src="/js/jquery.min.js"></script>
<script src="{{$.BasePath}}/js/jquery.easing.js"></script> <script src="/js/jquery.easing.js"></script>
<script src="{{$.BasePath}}/js/popper.min.js"></script> <script src="/js/popper.min.js"></script>
<script src="{{$.BasePath}}/js/bootstrap.bundle.min.js"></script> <script src="/js/bootstrap.bundle.min.js"></script>
</body> </body>
</html> </html>

View File

@@ -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="{{$.BasePath}}/img/header-logo.png" alt="Prolicht"/></a> <a class="navbar-brand" href="/"><img src="/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>

View File

@@ -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="{{$.BasePath}}/img/header-logo-small.png" style="width:50px; height:50px"/> <img slot="logo" src="/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}}

View File

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

View File

@@ -4,14 +4,10 @@ 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"
@@ -39,7 +35,6 @@ 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
} }
@@ -80,42 +75,13 @@ 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")),
) )
// Mount base path if configured // Serve static files
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.root.HandleFiles("/css", http.FS(fsMust(fs.Sub(apiStatics, "assets/css")))) s.server.HandleFiles("/css", http.FS(fsMust(fs.Sub(apiStatics, "assets/css"))))
s.root.HandleFiles("/js", http.FS(fsMust(fs.Sub(apiStatics, "assets/js")))) s.server.HandleFiles("/js", http.FS(fsMust(fs.Sub(apiStatics, "assets/js"))))
s.root.HandleFiles("/img", imgFs) s.server.HandleFiles("/img", imgFs)
s.root.HandleFiles("/fonts", http.FS(fsMust(fs.Sub(apiStatics, "assets/fonts")))) s.server.HandleFiles("/fonts", http.FS(fsMust(fs.Sub(apiStatics, "assets/fonts"))))
if cfg.Web.BasePath == "" { s.server.HandleFiles("/doc", http.FS(fsMust(fs.Sub(apiStatics, "assets/doc"))))
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...)
@@ -160,14 +126,14 @@ func (s *Server) Run(ctx context.Context, listenAddress string) {
} }
func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) { func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
s.root.HandleFunc("GET /api", s.landingPage) s.server.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.root.Mount(fmt.Sprintf("/api/%s", version)) s.versions[version] = s.server.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
@@ -181,196 +147,38 @@ func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
func (s *Server) setupFrontendRoutes() { func (s *Server) setupFrontendRoutes() {
// Serve static files // Serve static files
s.root.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s.server.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
respond.Redirect(w, r, http.StatusMovedPermanently, s.cfg.Web.BasePath+"/app") respond.Redirect(w, r, http.StatusMovedPermanently, "/app")
}) })
s.root.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { s.server.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
respond.Redirect(w, r, http.StatusMovedPermanently, s.cfg.Web.BasePath+"/app/favicon.ico") respond.Redirect(w, r, http.StatusMovedPermanently, "/app/favicon.ico")
}) })
// If a custom frontend path is configured, serve files from there when it contains content. s.server.HandleFiles("/app", http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
// 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{
"BasePath": s.cfg.Web.BasePath, "Version": internal.Version,
"Version": internal.Version, "Year": time.Now().Year(),
"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": s.cfg.Web.BasePath + "/js/rapidoc-min.js", "RapiDocSource": "/js/rapidoc-min.js",
"BasePath": s.cfg.Web.BasePath, "ApiSpecUrl": fmt.Sprintf("/doc/%s_swagger.yaml", version),
"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"))
}
// rewrite relative paths
newIndexStr := strings.ReplaceAll(string(customIndexFile), "src=\"./", "src=\""+s.cfg.Web.BasePath+"/app/")
newIndexStr = strings.ReplaceAll(newIndexStr, "href=\"./", "href=\""+s.cfg.Web.BasePath+"/app/")
// rewrite absolute paths (api calls)
newIndexStr = strings.ReplaceAll(newIndexStr, "src=\"/api/", "src=\""+s.cfg.Web.BasePath+"/api/")
re := regexp.MustCompile(`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), "./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()
})
}

View File

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

View File

@@ -2,7 +2,6 @@ package backend
import ( import (
"context" "context"
"fmt"
"io" "io"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
@@ -119,30 +118,3 @@ 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
}

View File

@@ -2,9 +2,6 @@ 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"
@@ -54,17 +51,6 @@ func (u UserService) GetAllUsers(ctx context.Context) ([]domain.User, error) {
} }
func (u UserService) UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error) { func (u UserService) UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error) {
sessionUser := domain.GetUserInfo(ctx)
currentUser, err := u.users.GetUser(ctx, user.Identifier)
if err != nil {
return nil, err
}
// if this endpoint is used by non-admins, make sure that the user can only modify a specific subset of attributes
if !sessionUser.IsAdmin {
user.CopyAdminAttributes(currentUser, u.cfg.Advanced.ApiAdminOnly)
}
return u.users.UpdateUser(ctx, user) return u.users.UpdateUser(ctx, user)
} }
@@ -84,50 +70,6 @@ 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)
} }
@@ -139,30 +81,3 @@ 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
}

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More