mirror of
https://github.com/h44z/wg-portal.git
synced 2026-02-23 19:06:34 +00:00
Compare commits
95 Commits
improve_we
...
fix/chart-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edfa509828 | ||
|
|
2d18776834 | ||
|
|
ee02916ce5 | ||
|
|
e62db0d62e | ||
|
|
129cd0d408 | ||
|
|
70cc44cc4d | ||
|
|
e53b8c8087 | ||
|
|
df9fdd14fb | ||
|
|
e0f6c1d04b | ||
|
|
d2fe267be7 | ||
|
|
bb516e9115 | ||
|
|
5d58df8a19 | ||
|
|
2200509bc0 | ||
|
|
1b56acac87 | ||
|
|
015220dc7b | ||
|
|
4b49a55ea2 | ||
|
|
93db40c995 | ||
|
|
0a88fe745f | ||
|
|
8cc937b031 | ||
|
|
54ca1d8aed | ||
|
|
a318118ee6 | ||
|
|
a8b4b23742 | ||
|
|
a1fcce6fde | ||
|
|
364f7b3a5b | ||
|
|
907bb0599a | ||
|
|
d759fc7dc7 | ||
|
|
67192170fc | ||
|
|
8f25bef050 | ||
|
|
8bc4990441 | ||
|
|
80dc7f290a | ||
|
|
de91506bfa | ||
|
|
380d71ba07 | ||
|
|
3d4a190949 | ||
|
|
df450cf384 | ||
|
|
9fbebc82f6 | ||
|
|
7c557d3e66 | ||
|
|
bda99464f1 | ||
|
|
d66a4b71b8 | ||
|
|
da76327569 | ||
|
|
c154cb3977 | ||
|
|
7bca35728d | ||
|
|
3d923b328e | ||
|
|
139fb17f98 | ||
|
|
faf1d995a8 | ||
|
|
f53d0b3d7f | ||
|
|
cdf3a49801 | ||
|
|
298c9405f6 | ||
|
|
c7724b620a | ||
|
|
4d19f1d8bb | ||
|
|
3f539a1615 | ||
|
|
0305911467 | ||
|
|
85f7a5a9a6 | ||
|
|
fb509a39b8 | ||
|
|
9e6ad98c4e | ||
|
|
05fbcccc9c | ||
|
|
97b6c398e8 | ||
|
|
cc2d1f53c4 | ||
|
|
b122e1ae60 | ||
|
|
ea26e56994 | ||
|
|
61bf349813 | ||
|
|
80693400be | ||
|
|
afb38b685c | ||
|
|
7cd7d13dc7 | ||
|
|
d945e313b2 | ||
|
|
c5fe82ab11 | ||
|
|
765fb09770 | ||
|
|
6d2a5fa6de | ||
|
|
891d499a18 | ||
|
|
db357b82d0 | ||
|
|
b61d84ec4f | ||
|
|
d311313cb4 | ||
|
|
0cbca61c15 | ||
|
|
c79a6c83a8 | ||
|
|
098a9fe23e | ||
|
|
41cab5f7ea | ||
|
|
708c558211 | ||
|
|
99df4ca3cd | ||
|
|
9884d8c002 | ||
|
|
b099e8abfa | ||
|
|
112f6bfb77 | ||
|
|
a86f83a219 | ||
|
|
131413b470 | ||
|
|
2246829151 | ||
|
|
c20f17cddf | ||
|
|
3f76aa416f | ||
|
|
6a8b28df88 | ||
|
|
ffef1f7b12 | ||
|
|
dc002b156b | ||
|
|
1794b8653a | ||
|
|
a6d985d3ce | ||
|
|
a7bd3b3f95 | ||
|
|
f286840964 | ||
|
|
edb88b5768 | ||
|
|
588bbca141 | ||
|
|
f08740991b |
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: h44z # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
12
.github/workflows/chart.yml
vendored
12
.github/workflows/chart.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -35,16 +35,16 @@ jobs:
|
||||
# ct lint requires Python 3.x to run following packages:
|
||||
# - yamale (https://github.com/23andMe/Yamale)
|
||||
# - yamllint (https://github.com/adrienverge/yamllint)
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- uses: helm/chart-testing-action@v2
|
||||
- uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
|
||||
|
||||
- name: Run chart-testing (lint)
|
||||
run: ct lint --config ct.yaml
|
||||
|
||||
- uses: nolar/setup-k3d-k3s@v1
|
||||
- uses: nolar/setup-k3d-k3s@293b8e5822a20bc0d5bcdd4826f1a665e72aba96 # v1.0.9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -60,9 +60,9 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
26
.github/workflows/docker-publish.yml
vendored
26
.github/workflows/docker-publish.yml
vendored
@@ -18,13 +18,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Get Version
|
||||
shell: bash
|
||||
@@ -32,14 +32,14 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
wgportal/wg-portal
|
||||
@@ -66,13 +66,9 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=semver,pattern=v{{major}}
|
||||
# add v{{major}} tag, even for beta or release-canidate releases
|
||||
type=match,pattern=(v\d),group=1,enable=${{ contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
|
||||
# add {{major}} tag, even for beta releases or release-canidate releases
|
||||
type=match,pattern=v(\d),group=1,enable=${{ contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
@@ -84,7 +80,7 @@ jobs:
|
||||
BUILD_VERSION=${{ env.BUILD_VERSION }}
|
||||
|
||||
- name: Export binaries from images
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
@@ -100,7 +96,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: binaries
|
||||
path: binaries/wg-portal_linux*
|
||||
@@ -114,12 +110,12 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download binaries
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: binaries
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
files: 'wg-portal_linux*'
|
||||
generate_release_notes: true
|
||||
|
||||
6
.github/workflows/pages.yml
vendored
6
.github/workflows/pages.yml
vendored
@@ -15,11 +15,11 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: 3.x
|
||||
|
||||
@@ -37,4 +37,4 @@ jobs:
|
||||
run: mike deploy --push --update-aliases ${{ github.ref_name }} latest
|
||||
env:
|
||||
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"
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN npm run build
|
||||
######
|
||||
# Build backend
|
||||
######
|
||||
FROM --platform=${BUILDPLATFORM} golang:1.24-alpine AS builder
|
||||
FROM --platform=${BUILDPLATFORM} golang:1.25-alpine AS builder
|
||||
# Set the working directory
|
||||
WORKDIR /build
|
||||
# Download dependencies
|
||||
@@ -50,9 +50,9 @@ COPY --from=builder /build/dist/wg-portal /
|
||||
######
|
||||
# Final image
|
||||
######
|
||||
FROM alpine:3.22
|
||||
FROM alpine:3.23
|
||||
# Install OS-level dependencies
|
||||
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools
|
||||
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools tzdata
|
||||
# Setup timezone
|
||||
ENV TZ=UTC
|
||||
# Copy binaries
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2020-2023 Christoph Haas
|
||||
Copyright (c) 2020-2025 Christoph Haas
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
|
||||
14
README.md
14
README.md
@@ -21,7 +21,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos
|
||||
## Features
|
||||
|
||||
* Self-hosted - the whole application is a single binary
|
||||
* Responsive multi-language web UI written in Vue.js
|
||||
* Responsive multi-language web UI with dark-mode written in Vue.js
|
||||
* Automatically selects IP from the network pool assigned to the client
|
||||
* QR-Code for convenient mobile client configuration
|
||||
* Sends email to the client with QR-code and client config
|
||||
@@ -32,6 +32,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos
|
||||
* Docker ready
|
||||
* Can be used with existing WireGuard setups
|
||||
* Support for multiple WireGuard interfaces
|
||||
* Supports multiple WireGuard backends (wgctrl, MikroTik, or pfSense)
|
||||
* Peer Expiry Feature
|
||||
* Handles route and DNS settings like wg-quick does
|
||||
* Exposes Prometheus metrics for monitoring and alerting
|
||||
@@ -61,6 +62,17 @@ For the complete documentation visit [wgportal.org](https://wgportal.org).
|
||||
|
||||
* MIT License. [MIT](LICENSE.txt) or <https://opensource.org/licenses/MIT>
|
||||
|
||||
## Contributors and Sponsors
|
||||
|
||||
Thanks so much for all your contributions! They’re truly appreciated and help keep WireGuard Portal moving ahead.
|
||||
|
||||
<a href="https://github.com/h44z/wg-portal/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=h44z/wg-portal" />
|
||||
</a>
|
||||
|
||||
Want to support the project? You can buy me a coffee or join as a contributor - every bit of support helps!
|
||||
[Become a sponsor!](https://github.com/sponsors/h44z)
|
||||
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Since the project was accepted by the Docker-Sponsored Open Source Program, the Docker image location has moved to [wgportal/wg-portal](https://hub.docker.com/r/wgportal/wg-portal).
|
||||
|
||||
@@ -7,7 +7,7 @@ If you believe you've found a security issue in one of the supported versions of
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| v2.x | :white_check_mark: |
|
||||
| v1.x | :white_check_mark: |
|
||||
| v1.x | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@@ -47,12 +47,11 @@ func main() {
|
||||
rawDb, err := adapters.NewDatabase(cfg.Database)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
database, err := adapters.NewSqlRepository(rawDb)
|
||||
database, err := adapters.NewSqlRepository(rawDb, cfg)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
wireGuard := adapters.NewWireGuardRepository()
|
||||
|
||||
wgQuick := adapters.NewWgQuickRepo()
|
||||
wireGuard, err := wireguard.NewControllerManager(cfg)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
mailer := adapters.NewSmtpMailRepo(cfg.Mail)
|
||||
|
||||
@@ -85,13 +84,14 @@ func main() {
|
||||
internal.AssertNoError(err)
|
||||
userManager.StartBackgroundJobs(ctx)
|
||||
|
||||
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager)
|
||||
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, cfg.Web.BasePath, eventBus, userManager)
|
||||
internal.AssertNoError(err)
|
||||
authenticator.StartBackgroundJobs(ctx)
|
||||
|
||||
webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database)
|
||||
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, database)
|
||||
internal.AssertNoError(err)
|
||||
wireGuardManager.StartBackgroundJobs(ctx)
|
||||
|
||||
@@ -105,7 +105,7 @@ func main() {
|
||||
mailManager, err := mail.NewMailManager(cfg, mailer, cfgFileManager, database, database)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
routeManager, err := route.NewRouteManager(cfg, eventBus, database)
|
||||
routeManager, err := route.NewRouteManager(cfg, eventBus, database, wireGuard)
|
||||
internal.AssertNoError(err)
|
||||
routeManager.StartBackgroundJobs(ctx)
|
||||
|
||||
@@ -133,8 +133,9 @@ func main() {
|
||||
apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers)
|
||||
apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces)
|
||||
apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers)
|
||||
apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth)
|
||||
apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard)
|
||||
apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth)
|
||||
apiV0EndpointWebsocket := handlersV0.NewWebsocketEndpoint(cfg, apiV0Auth, eventBus)
|
||||
|
||||
apiFrontend := handlersV0.NewRestApi(apiV0Session,
|
||||
apiV0EndpointAuth,
|
||||
@@ -144,6 +145,7 @@ func main() {
|
||||
apiV0EndpointPeers,
|
||||
apiV0EndpointConfig,
|
||||
apiV0EndpointTest,
|
||||
apiV0EndpointWebsocket,
|
||||
)
|
||||
|
||||
// endregion API v0 (SPA frontend)
|
||||
|
||||
@@ -11,7 +11,12 @@ core:
|
||||
|
||||
web:
|
||||
external_url: http://localhost:8888
|
||||
base_path: ""
|
||||
request_logging: true
|
||||
frontend_filepath: ""
|
||||
|
||||
mail:
|
||||
templates_path: ""
|
||||
|
||||
webhook:
|
||||
url: ""
|
||||
@@ -93,4 +98,16 @@ auth:
|
||||
admin_value_regex: ^true$
|
||||
admin_group_regex: ^admin-group-name$
|
||||
registration_enabled: true
|
||||
log_user_info: true
|
||||
log_user_info: true
|
||||
|
||||
backend:
|
||||
default: local
|
||||
pfsense:
|
||||
- id: pfsense1
|
||||
display_name: "Main pfSense Firewall"
|
||||
api_url: "https://pfsense.example.com" # Base URL without /api/v2 (endpoints already include it)
|
||||
api_key: "your-api-key" # Generate in pfSense under 'System' -> 'REST API' -> 'Keys'
|
||||
api_verify_tls: true
|
||||
api_timeout: 30s
|
||||
concurrency: 5
|
||||
debug: false
|
||||
@@ -2,7 +2,7 @@ apiVersion: v2
|
||||
name: wg-portal
|
||||
description: WireGuard Configuration Portal with LDAP, OAuth, OIDC authentication
|
||||
# Version is set to ensure compatibility with the chart's Ingress resource.
|
||||
kubeVersion: ">=1.19.0"
|
||||
kubeVersion: ">=1.19.0-0"
|
||||
type: application
|
||||
home: https://wgportal.org
|
||||
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
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 0.7.1
|
||||
version: 0.7.2
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# wg-portal
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
WireGuard Configuration Portal with LDAP, OAuth, OIDC authentication
|
||||
|
||||
@@ -12,7 +12,7 @@ WireGuard Configuration Portal with LDAP, OAuth, OIDC authentication
|
||||
|
||||
## Requirements
|
||||
|
||||
Kubernetes: `>=1.19.0`
|
||||
Kubernetes: `>=1.19.0-0`
|
||||
|
||||
## Installing the Chart
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ spec:
|
||||
{{- with .Values.revisionHistoryLimit }}
|
||||
revisionHistoryLimit: {{ . }}
|
||||
{{- end }}
|
||||
{{- with .Values.replicas }}
|
||||
replicas: {{ . }}
|
||||
{{- end }}
|
||||
{{- with .Values.strategy }}
|
||||
strategy: {{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -15,7 +15,7 @@ spec:
|
||||
http:
|
||||
paths:
|
||||
- path: {{ default "/" (urlParse (tpl .Values.config.web.external_url .)).path }}
|
||||
pathType: {{ default "ImplementationSpecific" .pathType }}
|
||||
pathType: {{ default "ImplementationSpecific" .Values.ingress.pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "wg-portal.fullname" . }}
|
||||
|
||||
@@ -8,6 +8,9 @@ spec:
|
||||
{{- with .Values.revisionHistoryLimit }}
|
||||
revisionHistoryLimit: {{ . }}
|
||||
{{- end }}
|
||||
{{- with .Values.replicas }}
|
||||
replicas: {{ . }}
|
||||
{{- end }}
|
||||
{{- with .Values.strategy }}
|
||||
updateStrategy: {{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
|
||||
BIN
docs/assets/images/wgportal_dark.png
Normal file
BIN
docs/assets/images/wgportal_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
BIN
docs/assets/images/wgportal_light.png
Normal file
BIN
docs/assets/images/wgportal_light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
@@ -11,6 +11,27 @@ core:
|
||||
create_default_peer: true
|
||||
self_provisioning_allowed: true
|
||||
|
||||
backend:
|
||||
# default backend decides where new interfaces are created
|
||||
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:
|
||||
- id: mikrotik # unique id, not "local"
|
||||
display_name: RouterOS RB5009 # optional nice name
|
||||
api_url: https://10.10.10.10/rest
|
||||
api_user: wgportal
|
||||
api_password: a-super-secret-password
|
||||
api_verify_tls: false # set to false only if using self-signed during testing
|
||||
api_timeout: 30s # maximum request duration
|
||||
concurrency: 5 # limit parallel REST calls to device
|
||||
debug: false # verbose logging for this backend
|
||||
ignored_interfaces: # ignore these interfaces during import
|
||||
- wgTest1
|
||||
- wgTest2
|
||||
|
||||
web:
|
||||
site_title: My WireGuard Server
|
||||
site_company_name: My Company
|
||||
@@ -46,8 +67,7 @@ auth:
|
||||
auth:
|
||||
ldap:
|
||||
# a sample LDAP provider with user sync enabled
|
||||
- id: ldap
|
||||
provider_name: Active Directory
|
||||
- provider_name: ldap
|
||||
url: ldap://srv-ad1.company.local:389
|
||||
bind_user: ldap_wireguard@company.local
|
||||
bind_pass: super-s3cr3t-ldap
|
||||
@@ -78,8 +98,7 @@ auth:
|
||||
oidc:
|
||||
# A sample Entra ID provider with environment variable substitution.
|
||||
# Only users with an @outlook.com email address are allowed to register or login.
|
||||
- id: azure
|
||||
provider_name: azure
|
||||
- provider_name: azure
|
||||
display_name: Login with</br>Entra ID
|
||||
registration_enabled: true
|
||||
base_url: "https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0"
|
||||
@@ -92,8 +111,7 @@ auth:
|
||||
- email
|
||||
|
||||
# a sample provider where users with the attribute `wg_admin` set to `true` are considered as admins
|
||||
- id: oidc-with-admin-attribute
|
||||
provider_name: google
|
||||
- provider_name: google
|
||||
display_name: Login with</br>Google
|
||||
base_url: https://accounts.google.com
|
||||
client_id: the-client-id-1234.apps.googleusercontent.com
|
||||
@@ -115,8 +133,7 @@ auth:
|
||||
log_user_info: true
|
||||
|
||||
# a sample provider where users in the group `the-admin-group` are considered as admins
|
||||
- id: oidc-with-admin-group
|
||||
provider_name: google2
|
||||
- provider_name: google2
|
||||
display_name: Login with</br>Google2
|
||||
base_url: https://accounts.google.com
|
||||
client_id: another-client-id-1234.apps.googleusercontent.com
|
||||
@@ -147,8 +164,7 @@ auth:
|
||||
oauth:
|
||||
# a sample provider where users with the attribute `this-attribute-must-be-true` set to `true` or `True`
|
||||
# are considered as admins
|
||||
- id: google_plain_oauth-with-admin-attribute
|
||||
provider_name: google3
|
||||
- provider_name: google3
|
||||
display_name: Login with</br>Google3
|
||||
client_id: another-client-id-1234.apps.googleusercontent.com
|
||||
client_secret: A_CLIENT_SECRET
|
||||
@@ -170,8 +186,7 @@ auth:
|
||||
|
||||
# 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
|
||||
- id: google_plain_oauth_with_groups
|
||||
provider_name: google4
|
||||
- provider_name: google4
|
||||
display_name: Login with</br>Google4
|
||||
client_id: another-client-id-1234.apps.googleusercontent.com
|
||||
client_secret: A_CLIENT_SECRET
|
||||
@@ -195,3 +210,5 @@ auth:
|
||||
registration_enabled: true
|
||||
log_user_info: true
|
||||
```
|
||||
|
||||
For more information, check out the usage documentation (e.g. [General Configuration](../usage/general.md) or [Backends Configuration](../usage/backends.md)).
|
||||
|
||||
@@ -16,6 +16,7 @@ core:
|
||||
admin_user: admin@wgportal.local
|
||||
admin_password: wgportal-default
|
||||
admin_api_token: ""
|
||||
disable_admin_user: false
|
||||
editable_keys: true
|
||||
create_default_peer: false
|
||||
create_default_peer_on_creation: false
|
||||
@@ -24,6 +25,10 @@ core:
|
||||
self_provisioning_allowed: false
|
||||
import_existing: true
|
||||
restore_state: true
|
||||
|
||||
backend:
|
||||
default: local
|
||||
local_resolvconf_prefix: tun.
|
||||
|
||||
advanced:
|
||||
log_level: info
|
||||
@@ -68,6 +73,8 @@ mail:
|
||||
auth_type: plain
|
||||
from: Wireguard Portal <noreply@wireguard.local>
|
||||
link_only: false
|
||||
allow_peer_email: false
|
||||
templates_path: ""
|
||||
|
||||
auth:
|
||||
oidc: []
|
||||
@@ -81,6 +88,7 @@ auth:
|
||||
web:
|
||||
listening_address: :8888
|
||||
external_url: http://localhost:8888
|
||||
base_path: ""
|
||||
site_company_name: WireGuard Portal
|
||||
site_title: WireGuard Portal
|
||||
session_identifier: wgPortalSession
|
||||
@@ -90,6 +98,7 @@ web:
|
||||
expose_host_info: false
|
||||
cert_file: ""
|
||||
key_File: ""
|
||||
frontend_filepath: ""
|
||||
|
||||
webhook:
|
||||
url: ""
|
||||
@@ -102,6 +111,7 @@ webhook:
|
||||
|
||||
Below you will find sections like
|
||||
[`core`](#core),
|
||||
[`backend`](#backend),
|
||||
[`advanced`](#advanced),
|
||||
[`database`](#database),
|
||||
[`statistics`](#statistics),
|
||||
@@ -120,105 +130,213 @@ More advanced options are found in the subsequent `Advanced` section.
|
||||
|
||||
### `admin_user`
|
||||
- **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.
|
||||
|
||||
### `admin_password`
|
||||
- **Default:** `wgportal-default`
|
||||
- **Environment Variable:** `WG_PORTAL_CORE_ADMIN_PASSWORD`
|
||||
- **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.
|
||||
|
||||
### `disable_admin_user`
|
||||
- **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.
|
||||
|
||||
### `admin_api_token`
|
||||
- **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.
|
||||
|
||||
### `editable_keys`
|
||||
- **Default:** `true`
|
||||
- **Environment Variable:** `WG_PORTAL_CORE_EDITABLE_KEYS`
|
||||
- **Description:** Allow editing of WireGuard key-pairs directly in the UI.
|
||||
|
||||
### `create_default_peer`
|
||||
- **Default:** `false`
|
||||
- **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for **all** server interfaces.
|
||||
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER`
|
||||
- **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for all server interfaces where the "Create default peer" flag is set.
|
||||
- **Important:** This option is only effective for interfaces where the "Create default peer" flag is set (via the UI).
|
||||
|
||||
### `create_default_peer_on_creation`
|
||||
- **Default:** `false`
|
||||
- **Description:** If an LDAP user is created (e.g., through LDAP sync) and has no peers, automatically create a new WireGuard peer for **all** server interfaces.
|
||||
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION`
|
||||
- **Description:** If an LDAP user is created (e.g., through LDAP sync) and has no peers, automatically create a new WireGuard peer for all server interfaces where the "Create default peer" flag is set.
|
||||
- **Important:** This option requires [create_default_peer](#create_default_peer) to be enabled.
|
||||
|
||||
### `re_enable_peer_after_user_enable`
|
||||
- **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.
|
||||
|
||||
### `delete_peer_after_user_deleted`
|
||||
- **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.
|
||||
|
||||
### `self_provisioning_allowed`
|
||||
- **Default:** `false`
|
||||
- **Environment Variable:** `WG_PORTAL_CORE_SELF_PROVISIONING_ALLOWED`
|
||||
- **Description:** Allow registered (non-admin) users to self-provision peers from their profile page.
|
||||
|
||||
### `import_existing`
|
||||
- **Default:** `true`
|
||||
- **Environment Variable:** `WG_PORTAL_CORE_IMPORT_EXISTING`
|
||||
- **Description:** On startup, import existing WireGuard interfaces and peers into WireGuard Portal.
|
||||
|
||||
### `restore_state`
|
||||
- **Default:** `true`
|
||||
- **Environment Variable:** `WG_PORTAL_CORE_RESTORE_STATE`
|
||||
- **Description:** Restore the WireGuard interface states (up/down) that existed before WireGuard Portal started.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
Configuration options for the WireGuard backend, which manages the WireGuard interfaces and peers.
|
||||
The current MikroTik backend is in **BETA** and may not support all features.
|
||||
|
||||
### `default`
|
||||
- **Default:** `local`
|
||||
- **Description:** The default backend to use for managing WireGuard interfaces.
|
||||
Valid options are: `local`, or other backend id's configured in the `mikrotik` section.
|
||||
|
||||
### `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`
|
||||
- **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.
|
||||
This is useful if you want to prevent certain interfaces from being imported from the local system.
|
||||
|
||||
### Mikrotik
|
||||
|
||||
The `mikrotik` array contains a list of MikroTik backend definitions. Each entry describes how to connect to a MikroTik RouterOS instance that hosts WireGuard interfaces.
|
||||
|
||||
Below are the properties for each entry inside `backend.mikrotik`:
|
||||
|
||||
#### `id`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** A unique identifier for this backend.
|
||||
This value can be referenced by `backend.default` to use this backend as default.
|
||||
The identifier must be unique across all backends and must not use the reserved keyword `local`.
|
||||
|
||||
#### `display_name`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** A human-friendly display name for this backend. If omitted, the `id` will be used as the display name.
|
||||
|
||||
#### `api_url`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** Base URL of the MikroTik REST API, including scheme and path, e.g., `https://10.10.10.10:8729/rest`.
|
||||
|
||||
#### `api_user`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** Username for authenticating against the MikroTik API.
|
||||
Ensure that the user has sufficient permissions to manage WireGuard interfaces and peers.
|
||||
|
||||
#### `api_password`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** Password for the specified API user.
|
||||
|
||||
#### `api_verify_tls`
|
||||
- **Default:** `false`
|
||||
- **Description:** Whether to verify the TLS certificate of the MikroTik API endpoint. Set to `false` to allow self-signed certificates (not recommended for production).
|
||||
|
||||
#### `api_timeout`
|
||||
- **Default:** `30s`
|
||||
- **Description:** Timeout for API requests to the MikroTik device. Uses Go duration format (e.g., `10s`, `1m`). If omitted, a default of 30 seconds is used.
|
||||
|
||||
#### `concurrency`
|
||||
- **Default:** `5`
|
||||
- **Description:** Maximum number of concurrent API requests the backend will issue when enumerating interfaces and their details. If `0` or negative, a sane default of `5` is used.
|
||||
|
||||
#### `ignored_interfaces`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** A list of interface names to exclude during interface enumeration.
|
||||
This is useful if you want to prevent specific interfaces from being imported from the MikroTik device.
|
||||
|
||||
#### `debug`
|
||||
- **Default:** `false`
|
||||
- **Description:** Enable verbose debug logging for the MikroTik backend.
|
||||
|
||||
For more details on configuring the MikroTik backend, see the [Backends](../usage/backends.md) documentation.
|
||||
|
||||
---
|
||||
|
||||
## Advanced
|
||||
|
||||
Additional or more specialized configuration options for logging and interface creation details.
|
||||
|
||||
### `log_level`
|
||||
- **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`.
|
||||
|
||||
### `log_pretty`
|
||||
- **Default:** `false`
|
||||
- **Environment Variable:** `WG_PORTAL_ADVANCED_LOG_PRETTY`
|
||||
- **Description:** If `true`, log messages are colorized and formatted for readability (pretty-print).
|
||||
|
||||
### `log_json`
|
||||
- **Default:** `false`
|
||||
- **Environment Variable:** `WG_PORTAL_ADVANCED_LOG_JSON`
|
||||
- **Description:** If `true`, log messages are structured in JSON format.
|
||||
|
||||
### `start_listen_port`
|
||||
- **Default:** `51820`
|
||||
- **Environment Variable:** `WG_PORTAL_ADVANCED_START_LISTEN_PORT`
|
||||
- **Description:** The first port to use when automatically creating new WireGuard interfaces.
|
||||
|
||||
### `start_cidr_v4`
|
||||
- **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.
|
||||
|
||||
### `start_cidr_v6`
|
||||
- **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.
|
||||
|
||||
### `use_ip_v6`
|
||||
- **Default:** `true`
|
||||
- **Environment Variable:** `WG_PORTAL_ADVANCED_USE_IP_V6`
|
||||
- **Description:** Enable or disable IPv6 support.
|
||||
|
||||
### `config_storage_path`
|
||||
- **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).
|
||||
|
||||
### `expiry_check_interval`
|
||||
- **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).
|
||||
|
||||
### `rule_prio_offset`
|
||||
- **Default:** `20000`
|
||||
- **Environment Variable:** `WG_PORTAL_ADVANCED_RULE_PRIO_OFFSET`
|
||||
- **Description:** Offset for IP route rule priorities when configuring routing.
|
||||
|
||||
### `route_table_offset`
|
||||
- **Default:** `20000`
|
||||
- **Environment Variable:** `WG_PORTAL_ADVANCED_ROUTE_TABLE_OFFSET`
|
||||
- **Description:** Offset for IP route table IDs when configuring routing.
|
||||
|
||||
### `api_admin_only`
|
||||
- **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).
|
||||
|
||||
### `limit_additional_user_peers`
|
||||
- **Default:** `0`
|
||||
- **Environment Variable:** `WG_PORTAL_ADVANCED_LIMIT_ADDITIONAL_USER_PEERS`
|
||||
- **Description:** Limit additional peers a normal user can create. `0` means unlimited.
|
||||
|
||||
---
|
||||
@@ -232,18 +350,22 @@ If sensitive values (like private keys) should be stored in an encrypted format,
|
||||
|
||||
### `debug`
|
||||
- **Default:** `false`
|
||||
- **Environment Variable:** `WG_PORTAL_DATABASE_DEBUG`
|
||||
- **Description:** If `true`, logs all database statements (verbose).
|
||||
|
||||
### `slow_query_threshold`
|
||||
- **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.
|
||||
|
||||
### `type`
|
||||
- **Default:** `sqlite`
|
||||
- **Environment Variable:** `WG_PORTAL_DATABASE_TYPE`
|
||||
- **Description:** The database type. Valid options: `sqlite`, `mssql`, `mysql`, `postgres`.
|
||||
|
||||
### `dsn`
|
||||
- **Default:** `data/sqlite.db`
|
||||
- **Environment Variable:** `WG_PORTAL_DATABASE_DSN`
|
||||
- **Description:** The Data Source Name (DSN) for connecting to the database.
|
||||
For example:
|
||||
```text
|
||||
@@ -252,6 +374,7 @@ If sensitive values (like private keys) should be stored in an encrypted format,
|
||||
|
||||
### `encryption_passphrase`
|
||||
- **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.
|
||||
**Important:** Once you enable encryption by setting this passphrase, you cannot disable it or change it afterward.
|
||||
New or updated records will be encrypted; existing data remains in plaintext until it’s next modified.
|
||||
@@ -264,82 +387,114 @@ Controls how WireGuard Portal collects and reports usage statistics, including p
|
||||
|
||||
### `use_ping_checks`
|
||||
- **Default:** `true`
|
||||
- **Environment Variable:** `WG_PORTAL_STATISTICS_USE_PING_CHECKS`
|
||||
- **Description:** Enable periodic ping checks to verify that peers remain responsive.
|
||||
|
||||
### `ping_check_workers`
|
||||
- **Default:** `10`
|
||||
- **Environment Variable:** `WG_PORTAL_STATISTICS_PING_CHECK_WORKERS`
|
||||
- **Description:** Number of parallel worker processes for ping checks.
|
||||
|
||||
### `ping_unprivileged`
|
||||
- **Default:** `false`
|
||||
- **Environment Variable:** `WG_PORTAL_STATISTICS_PING_UNPRIVILEGED`
|
||||
- **Description:** If `false`, ping checks run without root privileges. This is currently considered BETA.
|
||||
|
||||
### `ping_check_interval`
|
||||
- **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).
|
||||
|
||||
### `data_collection_interval`
|
||||
- **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).
|
||||
|
||||
### `collect_interface_data`
|
||||
- **Default:** `true`
|
||||
- **Environment Variable:** `WG_PORTAL_STATISTICS_COLLECT_INTERFACE_DATA`
|
||||
- **Description:** If `true`, collects interface-level data (bytes in/out) for monitoring and statistics.
|
||||
|
||||
### `collect_peer_data`
|
||||
- **Default:** `true`
|
||||
- **Environment Variable:** `WG_PORTAL_STATISTICS_COLLECT_PEER_DATA`
|
||||
- **Description:** If `true`, collects peer-level data (bytes, last handshake, endpoint, etc.).
|
||||
|
||||
### `collect_audit_data`
|
||||
- **Default:** `true`
|
||||
- **Environment Variable:** `WG_PORTAL_STATISTICS_COLLECT_AUDIT_DATA`
|
||||
- **Description:** If `true`, logs certain portal events (such as user logins) to the database.
|
||||
|
||||
### `listening_address`
|
||||
- **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`).
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
- **Default:** `127.0.0.1`
|
||||
- **Environment Variable:** `WG_PORTAL_MAIL_HOST`
|
||||
- **Description:** Hostname or IP of the SMTP server.
|
||||
|
||||
### `port`
|
||||
- **Default:** `25`
|
||||
- **Environment Variable:** `WG_PORTAL_MAIL_PORT`
|
||||
- **Description:** Port number for the SMTP server.
|
||||
|
||||
### `encryption`
|
||||
- **Default:** `none`
|
||||
- **Environment Variable:** `WG_PORTAL_MAIL_ENCRYPTION`
|
||||
- **Description:** SMTP encryption type. Valid values: `none`, `tls`, `starttls`.
|
||||
|
||||
### `cert_validation`
|
||||
- **Default:** `true`
|
||||
- **Environment Variable:** `WG_PORTAL_MAIL_CERT_VALIDATION`
|
||||
- **Description:** If `true`, validate the SMTP server certificate (relevant if `encryption` = `tls`).
|
||||
|
||||
### `username`
|
||||
- **Default:** *(empty)*
|
||||
- **Environment Variable:** `WG_PORTAL_MAIL_USERNAME`
|
||||
- **Description:** Optional SMTP username for authentication.
|
||||
|
||||
### `password`
|
||||
- **Default:** *(empty)*
|
||||
- **Environment Variable:** `WG_PORTAL_MAIL_PASSWORD`
|
||||
- **Description:** Optional SMTP password for authentication.
|
||||
|
||||
### `auth_type`
|
||||
- **Default:** `plain`
|
||||
- **Environment Variable:** `WG_PORTAL_MAIL_AUTH_TYPE`
|
||||
- **Description:** SMTP authentication type. Valid values: `plain`, `login`, `crammd5`.
|
||||
|
||||
### `from`
|
||||
- **Default:** `Wireguard Portal <noreply@wireguard.local>`
|
||||
- **Environment Variable:** `WG_PORTAL_MAIL_FROM`
|
||||
- **Description:** The default "From" address when sending emails.
|
||||
|
||||
### `link_only`
|
||||
- **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.
|
||||
|
||||
### `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
|
||||
@@ -351,12 +506,14 @@ Some core authentication options are shared across all providers, while others a
|
||||
|
||||
### `min_password_length`
|
||||
- **Default:** `16`
|
||||
- **Environment Variable:** `WG_PORTAL_AUTH_MIN_PASSWORD_LENGTH`
|
||||
- **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.
|
||||
- **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`
|
||||
- **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.
|
||||
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).
|
||||
@@ -419,13 +576,18 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
|
||||
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
|
||||
|
||||
#### `registration_enabled`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, a new user will be created in WireGuard Portal if not already present.
|
||||
|
||||
#### `log_user_info`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, OIDC user data is logged at the trace level upon login (for debugging).
|
||||
|
||||
#### `log_sensitive_info`
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
|
||||
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
|
||||
|
||||
---
|
||||
|
||||
### OAuth
|
||||
@@ -492,13 +654,18 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
|
||||
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
|
||||
|
||||
#### `registration_enabled`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, new users are created automatically on successful login.
|
||||
|
||||
#### `log_user_info`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, logs user info at the trace level upon login.
|
||||
|
||||
#### `log_sensitive_info`
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
|
||||
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
|
||||
|
||||
---
|
||||
|
||||
### LDAP
|
||||
@@ -515,11 +682,11 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
|
||||
- **Description:** The LDAP server URL (e.g., `ldap://srv-ad01.company.local:389`).
|
||||
|
||||
#### `start_tls`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, use STARTTLS to secure the LDAP connection.
|
||||
|
||||
#### `cert_validation`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, validate the LDAP server’s TLS certificate.
|
||||
|
||||
#### `tls_certificate_path`
|
||||
@@ -588,20 +755,24 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
|
||||
(&(objectClass=organizationalPerson)(!userAccountControl:1.2.840.113556.1.4.803:=2)(mail=*))
|
||||
```
|
||||
|
||||
#### `sync_log_user_info`
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, logs LDAP user data at the trace level during synchronization.
|
||||
|
||||
#### `disable_missing`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal.
|
||||
|
||||
#### `auto_re_enable`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, users that where disabled because they were missing (see `disable_missing`) will be re-enabled once they are found again.
|
||||
|
||||
#### `registration_enabled`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login.
|
||||
|
||||
#### `log_user_info`
|
||||
- **Default:** *(empty)*
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, logs LDAP user data at the trace level upon login.
|
||||
|
||||
---
|
||||
@@ -612,6 +783,7 @@ The `webauthn` section contains configuration options for WebAuthn authenticatio
|
||||
|
||||
#### `enabled`
|
||||
- **Default:** `true`
|
||||
- **Environment Variable:** `WG_PORTAL_AUTH_WEBAUTHN_ENABLED`
|
||||
- **Description:** If `true`, Passkey authentication is enabled. If `false`, WebAuthn is disabled.
|
||||
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.
|
||||
@@ -624,50 +796,76 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
|
||||
|
||||
### `listening_address`
|
||||
- **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).
|
||||
Ensure that access to WireGuard Portal is protected against unauthorized access, especially if binding to all interfaces.
|
||||
|
||||
### `external_url`
|
||||
- **Default:** `http://localhost:8888`
|
||||
- **Description:** The URL where a client can access WireGuard Portal. This URL is used for generating links in emails and for performing OAUTH redirects.
|
||||
- **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.
|
||||
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.
|
||||
|
||||
### `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`
|
||||
- **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.
|
||||
|
||||
### `site_title`
|
||||
- **Default:** `WireGuard Portal`
|
||||
- **Environment Variable:** `WG_PORTAL_WEB_SITE_TITLE`
|
||||
- **Description:** The title that is shown in the web frontend.
|
||||
|
||||
### `session_identifier`
|
||||
- **Default:** `wgPortalSession`
|
||||
- **Environment Variable:** `WG_PORTAL_WEB_SESSION_IDENTIFIER`
|
||||
- **Description:** The session identifier for the web frontend.
|
||||
|
||||
### `session_secret`
|
||||
- **Default:** `very_secret`
|
||||
- **Environment Variable:** `WG_PORTAL_WEB_SESSION_SECRET`
|
||||
- **Description:** The session secret for the web frontend.
|
||||
|
||||
### `csrf_secret`
|
||||
- **Default:** `extremely_secret`
|
||||
- **Environment Variable:** `WG_PORTAL_WEB_CSRF_SECRET`
|
||||
- **Description:** The CSRF secret.
|
||||
|
||||
### `request_logging`
|
||||
- **Default:** `false`
|
||||
- **Environment Variable:** `WG_PORTAL_WEB_REQUEST_LOGGING`
|
||||
- **Description:** Log all HTTP requests.
|
||||
|
||||
### `expose_host_info`
|
||||
- **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.
|
||||
|
||||
### `cert_file`
|
||||
- **Default:** *(empty)*
|
||||
- **Environment Variable:** `WG_PORTAL_WEB_CERT_FILE`
|
||||
- **Description:** (Optional) Path to the TLS certificate file.
|
||||
|
||||
### `key_file`
|
||||
- **Default:** *(empty)*
|
||||
- **Environment Variable:** `WG_PORTAL_WEB_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
|
||||
@@ -677,12 +875,15 @@ Further details can be found in the [usage documentation](../usage/webhooks.md).
|
||||
|
||||
### `url`
|
||||
- **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.
|
||||
|
||||
### `authentication`
|
||||
- **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>`.
|
||||
|
||||
### `timeout`
|
||||
- **Default:** `10s`
|
||||
- **Description:** The timeout for the webhook request. If the request takes longer than this, it is aborted.
|
||||
- **Environment Variable:** `WG_PORTAL_WEBHOOK_TIMEOUT`
|
||||
- **Description:** The timeout for the webhook request. If the request takes longer than this, it is aborted.
|
||||
|
||||
@@ -9,6 +9,11 @@ Make sure that you download the correct binary for your architecture. The availa
|
||||
- `wg-portal_linux_arm64` - Linux ARM 64-bit
|
||||
- `wg-portal_linux_arm_v7` - Linux ARM 32-bit
|
||||
|
||||
### Released versions
|
||||
|
||||
To download a specific version, replace `${WG_PORTAL_VERSION}` with the desired version (or set an environment variable).
|
||||
All official release versions can be found on the [GitHub Releases Page](https://github.com/h44z/wg-portal/releases).
|
||||
|
||||
With `curl`:
|
||||
|
||||
```shell
|
||||
@@ -27,16 +32,74 @@ with `gh cli`:
|
||||
gh release download ${WG_PORTAL_VERSION} --repo h44z/wg-portal --output wg-portal --pattern '*amd64'
|
||||
```
|
||||
|
||||
The downloaded file will be named `wg-portal` and can be moved to a directory of your choice, see [Install](#install) for more information.
|
||||
|
||||
### Unreleased versions (master branch builds)
|
||||
|
||||
Unreleased versions can be fetched directly from the artifacts section of the [GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster).
|
||||
|
||||
|
||||
## Install
|
||||
|
||||
The following command can be used to install the downloaded binary (`wg-portal`) to `/opt/wg-portal/wg-portal`. It ensures that the binary is executable.
|
||||
|
||||
```shell
|
||||
sudo mkdir -p /opt/wg-portal
|
||||
sudo install wg-portal /opt/wg-portal/
|
||||
```
|
||||
|
||||
## Unreleased versions (master branch builds)
|
||||
To handle tasks such as restarting the service or configuring automatic startup, it is recommended to use a process manager like [systemd](https://systemd.io/).
|
||||
Refer to [Systemd Service Setup](#systemd-service-setup) for instructions.
|
||||
|
||||
Unreleased versions can be fetched directly from the artifacts section of the [GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster).
|
||||
## Systemd Service Setup
|
||||
|
||||
> **Note:** To run WireGuard Portal as systemd service, you need to download the binary for your architecture beforehand.
|
||||
>
|
||||
> The following examples assume that you downloaded the binary to `/opt/wg-portal/wg-portal`.
|
||||
> The configuration file is expected to be located at `/opt/wg-portal/config.yml`.
|
||||
|
||||
To run WireGuard Portal as a systemd service, you can create a service unit file. The easiest way to do this is by using `systemctl edit`:
|
||||
|
||||
```shell
|
||||
sudo systemctl edit --force --full wg-portal.service
|
||||
```
|
||||
|
||||
Paste the following content into the editor and adjust the variables to your needs:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=WireGuard Portal
|
||||
ConditionPathExists=/opt/wg-portal/wg-portal
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
WorkingDirectory=/opt/wg-portal
|
||||
Environment=WG_PORTAL_CONFIG=/opt/wg-portal/config.yml
|
||||
ExecStart=/opt/wg-portal/wg-portal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Alternatively, you can create or modify the file manually in `/etc/systemd/system/wg-portal.service`.
|
||||
For systemd to pick up the changes, you need to reload the daemon:
|
||||
|
||||
```shell
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
After creating the service file, you can enable and start the service:
|
||||
|
||||
```shell
|
||||
sudo systemctl enable --now wg-portal.service
|
||||
```
|
||||
|
||||
To check status and log output, use: `sudo systemctl status wg-portal.service` or `sudo journalctl -u wg-portal.service`.
|
||||
|
||||
@@ -84,6 +84,16 @@ web:
|
||||
external_url: https://wg.domain.com
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
If you prefer to let WireGuard Portal handle TLS itself, you can use the built-in TLS support.
|
||||
|
||||
@@ -403,6 +403,12 @@ definitions:
|
||||
type: object
|
||||
models.ProvisioningRequest:
|
||||
properties:
|
||||
DisplayName:
|
||||
description: |-
|
||||
DisplayName is an optional name for the new peer.
|
||||
If unset, a default template value (e.g., "API Peer ...") will be assigned.
|
||||
example: API Peer xyz
|
||||
type: string
|
||||
InterfaceIdentifier:
|
||||
description: InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to.
|
||||
example: wg0
|
||||
@@ -437,6 +443,18 @@ definitions:
|
||||
maxLength: 64
|
||||
minLength: 32
|
||||
type: string
|
||||
AuthSources:
|
||||
description: The source of the user. This field is optional.
|
||||
example:
|
||||
- db
|
||||
items:
|
||||
enum:
|
||||
- db
|
||||
- ldap
|
||||
- oauth
|
||||
type: string
|
||||
readOnly: true
|
||||
type: array
|
||||
Department:
|
||||
description: The department of the user. This field is optional.
|
||||
example: Software Development
|
||||
@@ -497,17 +515,6 @@ definitions:
|
||||
description: The phone number of the user. This field is optional.
|
||||
example: "+1234546789"
|
||||
type: string
|
||||
ProviderName:
|
||||
description: The name of the authentication provider. This field is read-only.
|
||||
example: ""
|
||||
readOnly: true
|
||||
type: string
|
||||
Source:
|
||||
description: The source of the user. This field is optional.
|
||||
enum:
|
||||
- db
|
||||
example: db
|
||||
type: string
|
||||
required:
|
||||
- Identifier
|
||||
type: object
|
||||
|
||||
152
docs/documentation/usage/authentication.md
Normal file
152
docs/documentation/usage/authentication.md
Normal file
@@ -0,0 +1,152 @@
|
||||
WireGuard Portal supports multiple authentication mechanisms to manage user access. This includes
|
||||
|
||||
- Local user accounts
|
||||
- LDAP authentication
|
||||
- OAuth2 and OIDC authentication
|
||||
- Passkey authentication (WebAuthn)
|
||||
|
||||
Users can have two roles which limit their permissions in WireGuard Portal:
|
||||
|
||||
- **User**: Can manage their own account and peers.
|
||||
- **Admin**: Can manage all users and peers, including the ability to manage WireGuard interfaces.
|
||||
|
||||
In general, each user is identified by a _unique identifier_. If the same user identifier exists across multiple authentication sources, WireGuard Portal automatically merges those accounts into a single user record.
|
||||
When a user is associated with multiple authentication sources, their information in WireGuard Portal is updated based on the most recently logged-in source. For more details, see [User Synchronization](./user-sync.md) documentation.
|
||||
|
||||
## Password Authentication
|
||||
|
||||
WireGuard Portal supports username and password authentication for both local and LDAP-backed accounts.
|
||||
Local users are stored in the database, while LDAP users are authenticated against an external LDAP server.
|
||||
|
||||
On initial startup, WireGuard Portal automatically creates a local admin account with the password `wgportal-default`.
|
||||
> :warning: This password must be changed immediately after the first login.
|
||||
|
||||
The minimum password length for all local users can be configured in the [`auth`](../configuration/overview.md#auth)
|
||||
section of the configuration file. The default value is **16** characters, see [`min_password_length`](../configuration/overview.md#min_password_length).
|
||||
The minimum password length is also enforced for the default admin user.
|
||||
|
||||
|
||||
## Passkey (WebAuthn) Authentication
|
||||
|
||||
Besides the standard authentication mechanisms, WireGuard Portal supports Passkey authentication.
|
||||
This feature is enabled by default and can be configured in the [`webauthn`](../configuration/overview.md#webauthn-passkeys) section of the configuration file.
|
||||
|
||||
Users can register multiple Passkeys to their account. These Passkeys can be used to log in to the web UI as long as the user is not locked.
|
||||
> :warning: Passkey authentication does not disable password authentication. The password can still be used to log in (e.g., as a fallback).
|
||||
|
||||
To register a Passkey, open the settings page *(1)* in the web UI and click on the "Register Passkey" *(2)* button.
|
||||
|
||||

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

|
||||
|
||||
|
||||
### 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.
|
||||
WireGuard Portal supports multiple database backends. To reduce the risk of data exposure, sensitive information stored in the database can be encrypted.
|
||||
To enable encryption, set the [`encryption_passphrase`](../configuration/overview.md#database) in the database configuration section.
|
||||
|
||||
> :warning: Important: Once encryption is enabled, it cannot be disabled, and the passphrase cannot be changed!
|
||||
> Only new or updated records will be encrypted; existing data remains in plaintext until it’s next modified.
|
||||
|
||||
## UI and API Access
|
||||
|
||||
@@ -157,4 +16,9 @@ WireGuard Portal provides a web UI and a REST API for user interaction. It is im
|
||||
It is recommended to use HTTPS for all communication with the portal to prevent eavesdropping.
|
||||
|
||||
Event though, WireGuard Portal supports HTTPS out of the box, it is recommended to use a reverse proxy like Nginx or Traefik to handle SSL termination and other security features.
|
||||
A detailed explanation is available in the [Reverse Proxy](../getting-started/reverse-proxy.md) section.
|
||||
A detailed explanation is available in the [Reverse Proxy](../getting-started/reverse-proxy.md) section.
|
||||
|
||||
### Secure Authentication
|
||||
To prevent unauthorized access, WireGuard Portal supports integrating with secure authentication providers such as LDAP, OAuth2, or Passkeys, see [Authentication](./authentication.md) for more details.
|
||||
When possible, use centralized authentication and enforce multi-factor authentication (MFA) at the provider level for enhanced account security.
|
||||
For local accounts, administrators should enforce strong password requirements.
|
||||
46
docs/documentation/usage/user-sync.md
Normal file
46
docs/documentation/usage/user-sync.md
Normal file
@@ -0,0 +1,46 @@
|
||||
For all external authentication providers (LDAP, OIDC, OAuth2), WireGuard Portal can automatically create a local user record upon the user's first successful login.
|
||||
This behavior is controlled by the `registration_enabled` setting in each authentication provider's configuration.
|
||||
|
||||
User information from external authentication sources is merged into the corresponding local WireGuard Portal user record whenever the user logs in.
|
||||
Additionally, WireGuard Portal supports periodic synchronization of user data from an LDAP directory.
|
||||
|
||||
To prevent overwriting local changes, WireGuard Portal allows you to set a per-user flag that disables synchronization of external attributes.
|
||||
When this flag is set, the user in WireGuard Portal will not be updated automatically during log-ins or LDAP synchronization.
|
||||
|
||||
### LDAP Synchronization
|
||||
|
||||
WireGuard Portal lets you hook up any LDAP server such as Active Directory or OpenLDAP for both authentication and user sync.
|
||||
You can even register multiple LDAP servers side-by-side. Details on the log-in process can be found in the [LDAP Authentication](./authentication.md#ldap-authentication) section.
|
||||
|
||||
If you enable LDAP synchronization, all users within the LDAP directory will be created automatically in the WireGuard Portal database if they do not exist.
|
||||
If a user is disabled or deleted in LDAP, the user will be disabled in WireGuard Portal as well.
|
||||
The synchronization process can be fine-tuned by multiple parameters, which are described below.
|
||||
|
||||
#### Synchronization Parameters
|
||||
|
||||
To enable the LDAP sycnhronization this feature, set the `sync_interval` property in the LDAP provider configuration to a value greater than "0".
|
||||
The value is a string representing a duration, such as "15m" for 15 minutes or "1h" for 1 hour (check the [exact format definition](https://pkg.go.dev/time#ParseDuration) for details).
|
||||
The synchronization process will run in the background and synchronize users from LDAP to the database at the specified interval.
|
||||
Also make sure that the `sync_filter` property is a well-formed LDAP filter, or synchronization will fail.
|
||||
|
||||
##### Limiting Synchronization to Specific Users
|
||||
|
||||
Use the `sync_filter` property in your LDAP provider block to restrict which users get synchronized.
|
||||
It accepts any valid LDAP search filter, only entries matching that filter will be pulled into the portal's database.
|
||||
|
||||
For example, to import only users with a `mail` attribute:
|
||||
```yaml
|
||||
auth:
|
||||
ldap:
|
||||
- id: ldap
|
||||
# ... other settings
|
||||
sync_filter: (mail=*)
|
||||
```
|
||||
|
||||
##### Disable Missing Users
|
||||
|
||||
If you set the `disable_missing` property to `true`, any user that is not found in LDAP during synchronization will be disabled in WireGuard Portal.
|
||||
All peers associated with that user will also be disabled.
|
||||
|
||||
If you want a user and its peers to be automatically re-enabled once they are found in LDAP again, set the `auto_re_enable` property to `true`.
|
||||
This will only re-enable the user if they were disabled by the synchronization process. Manually disabled users will not be re-enabled.
|
||||
@@ -68,26 +68,32 @@ All payload models are encoded as JSON objects. Fields with empty values might b
|
||||
|
||||
#### User Payload (entity: `user`)
|
||||
|
||||
| JSON Field | Type | Description |
|
||||
|----------------|-------------|-----------------------------------|
|
||||
| CreatedBy | string | Creator identifier |
|
||||
| UpdatedBy | string | Last updater identifier |
|
||||
| CreatedAt | time.Time | Time of creation |
|
||||
| UpdatedAt | time.Time | Time of last update |
|
||||
| Identifier | string | Unique user identifier |
|
||||
| Email | string | User email |
|
||||
| Source | string | Authentication source |
|
||||
| ProviderName | string | Name of auth provider |
|
||||
| IsAdmin | bool | Whether user has admin privileges |
|
||||
| Firstname | string | User's first name (optional) |
|
||||
| Lastname | string | User's last name (optional) |
|
||||
| Phone | string | Contact phone number (optional) |
|
||||
| Department | string | User's department (optional) |
|
||||
| Notes | string | Additional notes (optional) |
|
||||
| Disabled | *time.Time | When user was disabled |
|
||||
| DisabledReason | string | Reason for deactivation |
|
||||
| Locked | *time.Time | When user account was locked |
|
||||
| LockedReason | string | Reason for being locked |
|
||||
| JSON Field | Type | Description |
|
||||
|----------------|---------------|-----------------------------------|
|
||||
| CreatedBy | string | Creator identifier |
|
||||
| UpdatedBy | string | Last updater identifier |
|
||||
| CreatedAt | time.Time | Time of creation |
|
||||
| UpdatedAt | time.Time | Time of last update |
|
||||
| Identifier | string | Unique user identifier |
|
||||
| Email | string | User email |
|
||||
| AuthSources | []AuthSource | Authentication sources |
|
||||
| IsAdmin | bool | Whether user has admin privileges |
|
||||
| Firstname | string | User's first name (optional) |
|
||||
| Lastname | string | User's last name (optional) |
|
||||
| Phone | string | Contact phone number (optional) |
|
||||
| Department | string | User's department (optional) |
|
||||
| Notes | string | Additional notes (optional) |
|
||||
| Disabled | *time.Time | When user was disabled |
|
||||
| DisabledReason | string | Reason for deactivation |
|
||||
| Locked | *time.Time | When user account was locked |
|
||||
| LockedReason | string | Reason for being locked |
|
||||
|
||||
`AuthSource`:
|
||||
|
||||
| JSON Field | Type | Description |
|
||||
|--------------|---------------|-----------------------------------------------------|
|
||||
| Source | string | The authentication source (e.g. LDAP, OAuth, or DB) |
|
||||
| ProviderName | string | The identifier of the authentication provider |
|
||||
|
||||
|
||||
#### Peer Payload (entity: `peer`)
|
||||
|
||||
2
docs/javascript/img-comparison-slider.js
Normal file
2
docs/javascript/img-comparison-slider.js
Normal file
File diff suppressed because one or more lines are too long
1
docs/javascript/img-comparison-slider.js.map
Normal file
1
docs/javascript/img-comparison-slider.js.map
Normal file
File diff suppressed because one or more lines are too long
15
docs/stylesheets/img-comparison-slider.css
Normal file
15
docs/stylesheets/img-comparison-slider.css
Normal file
@@ -0,0 +1,15 @@
|
||||
img-comparison-slider {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
img-comparison-slider [slot='second'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
img-comparison-slider.rendered {
|
||||
visibility: inherit;
|
||||
}
|
||||
|
||||
img-comparison-slider.rendered [slot='second'] {
|
||||
display: unset;
|
||||
}
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
.tx-hero__image {
|
||||
max-width: 1000px;
|
||||
min-width: 600px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
@@ -218,7 +218,7 @@
|
||||
|
||||
.secondary-section .g .section .component-wrapper .responsive-grid .card {
|
||||
position: relative;
|
||||
background-color: #fff none repeat scroll 0% 0%;
|
||||
background-color: #fff;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -300,6 +300,59 @@
|
||||
background: var(--md-accent-fg-color--transparent);
|
||||
}
|
||||
|
||||
.before,
|
||||
.after {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.after figcaption {
|
||||
background: #fff;
|
||||
font-weight: bold;
|
||||
border: 1px solid #c0c0c0;
|
||||
color: #000000;
|
||||
opacity: 0.9;
|
||||
padding: 9px;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
transform: translateY(-100%);
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
.before figcaption {
|
||||
background: #000;
|
||||
font-weight: bold;
|
||||
border: 1px solid #c0c0c0;
|
||||
color: #ffffff;
|
||||
opacity: 0.9;
|
||||
padding: 9px;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
transform: translateY(-100%);
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
.before figcaption {
|
||||
left: 0px;
|
||||
}
|
||||
.after figcaption {
|
||||
right: 0px;
|
||||
}
|
||||
.custom-animated-handle {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.slider-with-animated-handle:hover .custom-animated-handle {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
.md-typeset img-comparison-slider figure {
|
||||
margin: initial;
|
||||
}
|
||||
|
||||
.first-overlay {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
<!-- Hero for landing page -->
|
||||
@@ -310,7 +363,6 @@
|
||||
<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
|
||||
WireGuard VPN connections. It's built on top of WireGuard's official <span class="em">wgctrl</span> library.</p>
|
||||
</p>
|
||||
<a
|
||||
href="documentation/overview/"
|
||||
title="Get Started"
|
||||
@@ -326,11 +378,34 @@
|
||||
|
||||
<div class="md-container">
|
||||
<div class="tx-hero__image">
|
||||
<img
|
||||
src="{{config.site_url}}/assets/images/screenshot.png"
|
||||
alt=""
|
||||
draggable="false"
|
||||
>
|
||||
<div>
|
||||
<img-comparison-slider hover="hover">
|
||||
<figure slot="first" class="before">
|
||||
<img src="{{config.site_url}}/assets/images/wgportal_light.png" alt="Light Mode"/>
|
||||
<figcaption>Light Mode</figcaption>
|
||||
</figure>
|
||||
<figure slot="second" class="after">
|
||||
<img src="{{config.site_url}}/assets/images/wgportal_dark.png" alt="Dark Mode"/>
|
||||
<figcaption>Dark Mode</figcaption>
|
||||
</figure>
|
||||
<svg slot="handle" class="custom-animated-handle" xmlns="http://www.w3.org/2000/svg" width="100" viewBox="-8 -3 16 6">
|
||||
<!-- Left arrow (dark) -->
|
||||
<path d="M -5 -2 L -7 0 L -5 2 M -5 -2 L -5 2"
|
||||
stroke="#1a1a1a"
|
||||
fill="#1a1a1a"
|
||||
stroke-width="1"
|
||||
vector-effect="non-scaling-stroke">
|
||||
</path>
|
||||
<!-- Right arrow (white) -->
|
||||
<path d="M 5 -2 L 7 0 L 5 2 M 5 -2 L 5 2"
|
||||
stroke="#fff"
|
||||
fill="#fff"
|
||||
stroke-width="1"
|
||||
vector-effect="non-scaling-stroke">
|
||||
</path>
|
||||
</svg>
|
||||
</img-comparison-slider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link href="/favicon.ico" rel="icon" />
|
||||
@@ -9,6 +9,7 @@
|
||||
<script>
|
||||
// global config, will be overridden by backend if available
|
||||
let WGPORTAL_BACKEND_BASE_URL="http://localhost:5000/api/v0";
|
||||
let WGPORTAL_BASE_PATH="";
|
||||
let WGPORTAL_VERSION="unknown";
|
||||
let WGPORTAL_SITE_TITLE="WireGuard Portal";
|
||||
let WGPORTAL_SITE_COMPANY_NAME="WireGuard Portal";
|
||||
|
||||
1704
frontend/package-lock.json
generated
1704
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,28 +8,29 @@
|
||||
"preview": "vite preview --port 5050"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/nunito-sans": "^5.2.5",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@kyvg/vue3-notification": "^3.4.1",
|
||||
"@fontsource/nunito-sans": "^5.2.7",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"@kyvg/vue3-notification": "^3.4.2",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@vojtechlanka/vue-tags-input": "^3.1.1",
|
||||
"bootstrap": "^5.3.5",
|
||||
"bootswatch": "^5.3.5",
|
||||
"flag-icons": "^7.3.2",
|
||||
"ip-address": "^10.0.1",
|
||||
"is-cidr": "^5.1.1",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootswatch": "^5.3.8",
|
||||
"cidr-tools": "^11.0.3",
|
||||
"flag-icons": "^7.5.0",
|
||||
"ip-address": "^10.1.0",
|
||||
"is-cidr": "^6.0.1",
|
||||
"is-ip": "^5.0.1",
|
||||
"pinia": "^3.0.2",
|
||||
"pinia": "^3.0.4",
|
||||
"prismjs": "^1.30.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.1.3",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-prism-component": "github:h44z/vue-prism-component",
|
||||
"vue-router": "^4.5.0"
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"sass-embedded": "^1.86.3",
|
||||
"vite": "6.3.4"
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"sass-embedded": "^1.93.3",
|
||||
"vite": "^7.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { RouterLink, RouterView } from 'vue-router';
|
||||
import { computed, getCurrentInstance, onMounted, ref } from "vue";
|
||||
import {computed, getCurrentInstance, nextTick, onMounted, ref} from "vue";
|
||||
import { authStore } from "./stores/auth";
|
||||
import { securityStore } from "./stores/security";
|
||||
import { settingsStore } from "@/stores/settings";
|
||||
@@ -11,9 +11,14 @@ const auth = authStore()
|
||||
const sec = securityStore()
|
||||
const settings = settingsStore()
|
||||
|
||||
const currentTheme = ref("auto")
|
||||
|
||||
onMounted(async () => {
|
||||
console.log("Starting WireGuard Portal frontend...");
|
||||
|
||||
// restore theme from localStorage
|
||||
switchTheme(getTheme());
|
||||
|
||||
await sec.LoadSecurityProperties();
|
||||
await auth.LoadProviders();
|
||||
|
||||
@@ -40,6 +45,25 @@ const switchLanguage = function (lang) {
|
||||
}
|
||||
}
|
||||
|
||||
const getTheme = function () {
|
||||
return localStorage.getItem('wgTheme') || 'auto';
|
||||
}
|
||||
|
||||
const switchTheme = function (theme) {
|
||||
let bsTheme = 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);
|
||||
document.documentElement.setAttribute('data-bs-theme', bsTheme);
|
||||
}
|
||||
}
|
||||
|
||||
const languageFlag = computed(() => {
|
||||
// `this` points to the component instance
|
||||
let lang = appGlobal.$i18n.locale.toLowerCase();
|
||||
@@ -52,6 +76,7 @@ const languageFlag = computed(() => {
|
||||
uk: "ua",
|
||||
zh: "cn",
|
||||
ko: "kr",
|
||||
es: "es",
|
||||
|
||||
};
|
||||
return "fi-" + (langMap[lang] || lang);
|
||||
@@ -60,6 +85,7 @@ const languageFlag = computed(() => {
|
||||
const companyName = ref(WGPORTAL_SITE_COMPANY_NAME);
|
||||
const wgVersion = ref(WGPORTAL_VERSION);
|
||||
const currentYear = ref(new Date().getFullYear())
|
||||
const webBasePath = ref(WGPORTAL_BASE_PATH);
|
||||
|
||||
const userDisplayName = computed(() => {
|
||||
let displayName = "Unknown";
|
||||
@@ -88,7 +114,7 @@ const userDisplayName = computed(() => {
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/"><img :alt="companyName" src="/img/header-logo.png" /></a>
|
||||
<RouterLink class="navbar-brand" :to="{ name: 'home' }"><img :alt="companyName" :src="webBasePath + '/img/header-logo.png'" /></RouterLink>
|
||||
<button aria-controls="navbarColor01" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"
|
||||
data-bs-target="#navbarTop" data-bs-toggle="collapse" type="button">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
@@ -108,6 +134,9 @@ const userDisplayName = computed(() => {
|
||||
<li class="nav-item">
|
||||
<RouterLink :to="{ name: 'key-generator' }" class="nav-link">{{ $t('menu.keygen') }}</RouterLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<RouterLink :to="{ name: 'ip-calculator' }" class="nav-link">{{ $t('menu.calculator') }}</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="navbar-nav d-flex justify-content-end">
|
||||
@@ -125,6 +154,29 @@ const userDisplayName = computed(() => {
|
||||
<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>
|
||||
</div>
|
||||
<div class="nav-item dropdown" :key="currentTheme">
|
||||
<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>
|
||||
<span class="d-lg-none ms-2">Toggle theme</span>
|
||||
</a>
|
||||
<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>
|
||||
<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>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,7 +193,7 @@ const userDisplayName = computed(() => {
|
||||
<div class="col-6 text-end">
|
||||
<div :aria-label="$t('menu.lang')" class="btn-group" role="group">
|
||||
<div class="btn-group" role="group">
|
||||
<button aria-expanded="false" aria-haspopup="true" class="btn btn btn-secondary pe-0"
|
||||
<button aria-expanded="false" aria-haspopup="true" class="btn flag-button pe-0"
|
||||
data-bs-toggle="dropdown" type="button"><span :class="languageFlag" class="fi"></span></button>
|
||||
<div aria-labelledby="btnGroupDrop3" class="dropdown-menu" style="">
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('de')"><span class="fi fi-de"></span> Deutsch</a>
|
||||
@@ -153,6 +205,7 @@ 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('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('es')"><span class="fi fi-es"></span> Español</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -163,4 +216,35 @@ const userDisplayName = computed(() => {
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
<style>
|
||||
.flag-button:active,.flag-button:hover,.flag-button:focus,.flag-button:checked,.flag-button:disabled,.flag-button:not(:disabled) {
|
||||
border: 1px solid transparent!important;
|
||||
}
|
||||
[data-bs-theme=dark] .form-select {
|
||||
color: #0c0c0c!important;
|
||||
background-color: #c1c1c1!important;
|
||||
--bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")!important;
|
||||
}
|
||||
[data-bs-theme=dark] .form-control {
|
||||
color: #0c0c0c!important;
|
||||
background-color: #c1c1c1!important;
|
||||
}
|
||||
[data-bs-theme=dark] .form-control:focus {
|
||||
color: #0c0c0c!important;
|
||||
background-color: #c1c1c1!important;
|
||||
}
|
||||
[data-bs-theme=dark] .badge.bg-light {
|
||||
--bs-bg-opacity: 1;
|
||||
background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important;
|
||||
color: var(--bs-badge-color)!important;
|
||||
}
|
||||
[data-bs-theme=dark] span.input-group-text {
|
||||
--bs-bg-opacity: 1;
|
||||
background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important;
|
||||
color: var(--bs-badge-color)!important;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] .navbar-dark, .navbar {
|
||||
background-color: #000 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -65,6 +65,14 @@ a.disabled {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] .vue-tags-input .ti-tag {
|
||||
position: relative;
|
||||
background: #3c3c3c;
|
||||
border: 2px solid var(--bs-body-color);
|
||||
margin: 6px;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
/* the styles if a tag is invalid */
|
||||
.vue-tags-input .ti-tag.ti-invalid {
|
||||
background-color: #e88a74;
|
||||
@@ -96,4 +104,8 @@ a.disabled {
|
||||
|
||||
.vue-tags-input .ti-deletion-mark:after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -10,11 +10,13 @@ import isCidr from "is-cidr";
|
||||
import {isIP} from 'is-ip';
|
||||
import { freshInterface } from '@/helpers/models';
|
||||
import {peerStore} from "@/stores/peers";
|
||||
import {settingsStore} from "@/stores/settings";
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const interfaces = interfaceStore()
|
||||
const peers = peerStore()
|
||||
const settings = settingsStore()
|
||||
|
||||
const props = defineProps({
|
||||
interfaceId: String,
|
||||
@@ -48,6 +50,26 @@ const currentTags = ref({
|
||||
PeerDefDnsSearch: ""
|
||||
})
|
||||
const formData = ref(freshInterface())
|
||||
const isSaving = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
const isApplyingDefaults = ref(false)
|
||||
|
||||
const isBackendValid = computed(() => {
|
||||
if (!props.visible || !selectedInterface.value) {
|
||||
return true // if modal is not visible or no interface is selected, we don't care about backend validity
|
||||
}
|
||||
|
||||
let backendId = selectedInterface.value.Backend
|
||||
|
||||
let valid = false
|
||||
let availableBackends = settings.Setting('AvailableBackends') || []
|
||||
availableBackends.forEach(backend => {
|
||||
if (backend.Id === backendId) {
|
||||
valid = true
|
||||
}
|
||||
})
|
||||
return valid
|
||||
})
|
||||
|
||||
// functions
|
||||
|
||||
@@ -61,6 +83,8 @@ watch(() => props.visible, async (newValue, oldValue) => {
|
||||
formData.value.Identifier = interfaces.Prepared.Identifier
|
||||
formData.value.DisplayName = interfaces.Prepared.DisplayName
|
||||
formData.value.Mode = interfaces.Prepared.Mode
|
||||
formData.value.CreateDefaultPeer = interfaces.Prepared.CreateDefaultPeer
|
||||
formData.value.Backend = interfaces.Prepared.Backend
|
||||
|
||||
formData.value.PublicKey = interfaces.Prepared.PublicKey
|
||||
formData.value.PrivateKey = interfaces.Prepared.PrivateKey
|
||||
@@ -99,6 +123,8 @@ watch(() => props.visible, async (newValue, oldValue) => {
|
||||
formData.value.Identifier = selectedInterface.value.Identifier
|
||||
formData.value.DisplayName = selectedInterface.value.DisplayName
|
||||
formData.value.Mode = selectedInterface.value.Mode
|
||||
formData.value.CreateDefaultPeer = selectedInterface.value.CreateDefaultPeer
|
||||
formData.value.Backend = selectedInterface.value.Backend
|
||||
|
||||
formData.value.PublicKey = selectedInterface.value.PublicKey
|
||||
formData.value.PrivateKey = selectedInterface.value.PrivateKey
|
||||
@@ -237,6 +263,8 @@ function handleChangePeerDefDnsSearch(tags) {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (props.interfaceId!=='#NEW#') {
|
||||
await interfaces.UpdateInterface(selectedInterface.value.Identifier, formData.value)
|
||||
@@ -251,6 +279,8 @@ async function save() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +289,8 @@ async function applyPeerDefaults() {
|
||||
return; // do nothing for new interfaces
|
||||
}
|
||||
|
||||
if (isApplyingDefaults.value) return
|
||||
isApplyingDefaults.value = true
|
||||
try {
|
||||
await interfaces.ApplyPeerDefaults(selectedInterface.value.Identifier, formData.value)
|
||||
|
||||
@@ -276,12 +308,26 @@ async function applyPeerDefaults() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isApplyingDefaults.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function del() {
|
||||
if (isDeleting.value) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
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()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
@@ -290,6 +336,8 @@ async function del() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,13 +362,22 @@ async function del() {
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.identifier.label') }}</label>
|
||||
<input v-model="formData.Identifier" class="form-control" :placeholder="$t('modals.interface-edit.identifier.placeholder')" type="text">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.mode.label') }}</label>
|
||||
<select v-model="formData.Mode" class="form-select">
|
||||
<option value="server">{{ $t('modals.interface-edit.mode.server') }}</option>
|
||||
<option value="client">{{ $t('modals.interface-edit.mode.client') }}</option>
|
||||
<option value="any">{{ $t('modals.interface-edit.mode.any') }}</option>
|
||||
</select>
|
||||
<div class="row">
|
||||
<div class="form-group col-md-6">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.mode.label') }}</label>
|
||||
<select v-model="formData.Mode" class="form-select">
|
||||
<option value="server">{{ $t('modals.interface-edit.mode.server') }}</option>
|
||||
<option value="client">{{ $t('modals.interface-edit.mode.client') }}</option>
|
||||
<option value="any">{{ $t('modals.interface-edit.mode.any') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label class="form-label mt-4" for="ifaceBackendSelector">{{ $t('modals.interface-edit.backend.label') }}</label>
|
||||
<select id="ifaceBackendSelector" v-model="formData.Backend" class="form-select" aria-describedby="backendHelp">
|
||||
<option v-for="backend in settings.Setting('AvailableBackends')" :value="backend.Id">{{ backend.Id === 'local' ? $t(backend.Name) : backend.Name }}</option>
|
||||
</select>
|
||||
<small v-if="!isBackendValid" id="backendHelp" class="form-text text-warning">{{ $t('modals.interface-edit.backend.invalid-label') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.display-name.label') }}</label>
|
||||
@@ -385,12 +442,19 @@ async function del() {
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.mtu.label') }}</label>
|
||||
<input v-model="formData.Mtu" class="form-control" :placeholder="$t('modals.interface-edit.mtu.placeholder')" type="number">
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<div class="form-group col-md-6" v-if="formData.Backend==='local'">
|
||||
<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">
|
||||
</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>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="row" v-if="formData.Backend==='local'">
|
||||
<div class="form-group col-md-6">
|
||||
<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">
|
||||
@@ -400,7 +464,7 @@ async function del() {
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<fieldset v-if="formData.Backend==='local'">
|
||||
<legend class="mt-4">{{ $t('modals.interface-edit.header-hooks') }}</legend>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.pre-up.label') }}</label>
|
||||
@@ -425,7 +489,11 @@ async function del() {
|
||||
<input v-model="formData.Disabled" class="form-check-input" type="checkbox">
|
||||
<label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<div class="form-check form-switch" v-if="formData.Mode==='server' && settings.Setting('CreateDefaultPeer')">
|
||||
<input v-model="formData.CreateDefaultPeer" class="form-check-input" type="checkbox">
|
||||
<label class="form-check-label">{{ $t('modals.interface-edit.create-default-peer.label') }}</label>
|
||||
</div>
|
||||
<div class="form-check form-switch" v-if="formData.Backend==='local'">
|
||||
<input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox">
|
||||
<label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label>
|
||||
</div>
|
||||
@@ -530,16 +598,25 @@ async function del() {
|
||||
</fieldset>
|
||||
<fieldset v-if="props.interfaceId!=='#NEW#'" class="text-end">
|
||||
<hr class="mt-4">
|
||||
<button class="btn btn-primary me-1" type="button" @click.prevent="applyPeerDefaults">{{ $t('modals.interface-edit.button-apply-defaults') }}</button>
|
||||
<button class="btn btn-primary me-1" type="button" @click.prevent="applyPeerDefaults" :disabled="isApplyingDefaults">
|
||||
<span v-if="isApplyingDefaults" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
{{ $t('modals.interface-edit.button-apply-defaults') }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex-fill text-start">
|
||||
<button v-if="props.interfaceId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
|
||||
<button v-if="props.interfaceId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del" :disabled="isDeleting">
|
||||
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
{{ $t('general.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
|
||||
<button class="btn btn-primary me-1" type="button" @click.prevent="save" :disabled="isSaving">
|
||||
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
{{ $t('general.save') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
@@ -73,6 +73,8 @@ const currentTags = ref({
|
||||
DnsSearch: ""
|
||||
})
|
||||
const formData = ref(freshPeer())
|
||||
const isSaving = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
// functions
|
||||
|
||||
@@ -270,6 +272,8 @@ function handleChangeDnsSearch(tags) {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (props.peerId !== '#NEW#') {
|
||||
await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value)
|
||||
@@ -278,26 +282,30 @@ async function save() {
|
||||
}
|
||||
close()
|
||||
} catch (e) {
|
||||
// console.log(e)
|
||||
notify({
|
||||
title: "Failed to save peer!",
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function del() {
|
||||
if (isDeleting.value) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await peers.DeletePeer(selectedPeer.value.Identifier)
|
||||
close()
|
||||
} catch (e) {
|
||||
// console.log(e)
|
||||
notify({
|
||||
title: "Failed to delete peer!",
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +358,7 @@ async function del() {
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint.placeholder')"
|
||||
v-model="formData.Endpoint.Value">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-group" v-if="selectedInterface.Mode !== 'client'">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.ip.label') }}</label>
|
||||
<vue-tags-input class="form-control" v-model="currentTags.Addresses"
|
||||
:tags="formData.Addresses.map(str => ({ text: str }))"
|
||||
@@ -470,10 +478,15 @@ async function del() {
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex-fill text-start">
|
||||
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{
|
||||
$t('general.delete') }}</button>
|
||||
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del" :disabled="isDeleting">
|
||||
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
{{ $t('general.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
|
||||
<button class="btn btn-primary me-1" type="button" @click.prevent="save" :disabled="isSaving">
|
||||
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
{{ $t('general.save') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
@@ -32,12 +32,13 @@ const selectedInterface = computed(() => {
|
||||
function freshForm() {
|
||||
return {
|
||||
Identifiers: [],
|
||||
Suffix: "",
|
||||
Prefix: "",
|
||||
}
|
||||
}
|
||||
|
||||
const currentTag = ref("")
|
||||
const formData = ref(freshForm())
|
||||
const isSaving = ref(false)
|
||||
|
||||
const title = computed(() => {
|
||||
if (!props.visible) {
|
||||
@@ -60,12 +61,15 @@ function handleChangeUserIdentifiers(tags) {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return
|
||||
isSaving.value = true
|
||||
if (formData.value.Identifiers.length === 0) {
|
||||
notify({
|
||||
title: "Missing Identifiers",
|
||||
text: "At least one identifier is required to create a new peer.",
|
||||
type: 'error',
|
||||
})
|
||||
isSaving.value = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -79,6 +83,8 @@ async function save() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,13 +108,16 @@ async function save() {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-multi-create.prefix.label') }}</label>
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-multi-create.prefix.placeholder')" v-model="formData.Suffix">
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-multi-create.prefix.placeholder')" v-model="formData.Prefix">
|
||||
<small class="form-text text-muted">{{ $t('modals.peer-multi-create.prefix.description') }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
|
||||
<button class="btn btn-primary me-1" type="button" @click.prevent="save" :disabled="isSaving">
|
||||
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
{{ $t('general.save') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
@@ -50,7 +50,7 @@ const selectedStats = computed(() => {
|
||||
|
||||
if (!s) {
|
||||
if (!!props.peerId || props.peerId.length) {
|
||||
p = profile.Statistics(props.peerId)
|
||||
s = profile.Statistics(props.peerId)
|
||||
} else {
|
||||
s = freshStats() // dummy stats to avoid 'undefined' exceptions
|
||||
}
|
||||
@@ -79,13 +79,19 @@ const title = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const configStyle = ref("wgquick")
|
||||
|
||||
watch(() => props.visible, async (newValue, oldValue) => {
|
||||
if (oldValue === false && newValue === true) { // if modal is shown
|
||||
await peers.LoadPeerConfig(selectedPeer.value.Identifier)
|
||||
await peers.LoadPeerConfig(selectedPeer.value.Identifier, configStyle.value)
|
||||
configString.value = peers.configuration
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
watch(() => configStyle.value, async () => {
|
||||
await peers.LoadPeerConfig(selectedPeer.value.Identifier, configStyle.value)
|
||||
configString.value = peers.configuration
|
||||
})
|
||||
|
||||
function download() {
|
||||
// credit: https://www.bitdegree.org/learn/javascript-download
|
||||
@@ -103,7 +109,7 @@ function download() {
|
||||
}
|
||||
|
||||
function email() {
|
||||
peers.MailPeerConfig(settings.Setting("MailLinkOnly"), [selectedPeer.value.Identifier]).catch(e => {
|
||||
peers.MailPeerConfig(settings.Setting("MailLinkOnly"), configStyle.value, [selectedPeer.value.Identifier]).catch(e => {
|
||||
notify({
|
||||
title: "Failed to send mail with peer configuration!",
|
||||
text: e.toString(),
|
||||
@@ -114,7 +120,7 @@ function email() {
|
||||
|
||||
function ConfigQrUrl() {
|
||||
if (props.peerId.length) {
|
||||
return apiWrapper.url(`/peer/config-qr/${base64_url_encode(props.peerId)}`)
|
||||
return apiWrapper.url(`/peer/config-qr/${base64_url_encode(props.peerId)}?style=${configStyle.value}`)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -124,6 +130,15 @@ function ConfigQrUrl() {
|
||||
<template>
|
||||
<Modal :title="title" :visible="visible" @close="close">
|
||||
<template #default>
|
||||
<div class="d-flex justify-content-end align-items-center mb-1" v-if="selectedInterface.Mode !== 'client'">
|
||||
<span class="me-2">{{ $t('modals.peer-view.style-label') }}: </span>
|
||||
<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">
|
||||
<label class="btn btn-outline-dark btn-sm" for="raw">Raw</label>
|
||||
<input type="radio" class="btn-check" name="configstyle" id="wgquick" value="wgquick" autocomplete="off" checked="" v-model="configStyle">
|
||||
<label class="btn btn-outline-dark btn-sm" for="wgquick">WG-Quick</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion" id="peerInformation">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
@@ -136,20 +151,28 @@ function ConfigQrUrl() {
|
||||
data-bs-parent="#peerInformation" style="">
|
||||
<div class="accordion-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div :class="{ 'col-md-8': selectedInterface.Mode !== 'client', 'col-md-12': selectedInterface.Mode !== 'server' }" class="col-md-8">
|
||||
<ul>
|
||||
<li>{{ $t('modals.peer-view.identifier') }}: {{ selectedPeer.PublicKey }}</li>
|
||||
<li>{{ $t('modals.peer-view.ip') }}: <span v-for="ip in selectedPeer.Addresses" :key="ip"
|
||||
<li v-if="selectedInterface.Mode !== 'client'"><strong>{{ $t('modals.peer-view.identifier') }}</strong>: {{ selectedPeer.PublicKey }}</li>
|
||||
<li v-if="selectedInterface.Mode !== 'server'"><strong>{{ $t('modals.peer-view.endpoint-key') }}</strong>: {{ selectedPeer.PublicKey }}</li>
|
||||
<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>
|
||||
<li>{{ $t('modals.peer-view.user') }}: {{ selectedPeer.UserIdentifier }}</li>
|
||||
<li v-if="selectedPeer.Notes">{{ $t('modals.peer-view.notes') }}: {{ selectedPeer.Notes }}</li>
|
||||
<li v-if="selectedPeer.ExpiresAt">{{ $t('modals.peer-view.expiry-status') }}: {{
|
||||
<li v-if="selectedInterface.Mode === 'server'"><strong>{{ $t('modals.peer-view.extra-allowed-ip') }}</strong>: <span v-for="ip in selectedPeer.ExtraAllowedIPs" :key="ip"
|
||||
class="badge rounded-pill bg-light">{{ ip }}</span></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"
|
||||
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>
|
||||
<li v-if="selectedPeer.Disabled">{{ $t('modals.peer-view.disabled-status') }}: {{
|
||||
<li v-if="selectedPeer.Disabled"><strong>{{ $t('modals.peer-view.disabled-status') }}</strong>: {{
|
||||
selectedPeer.DisabledReason }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-4" v-if="selectedInterface.Mode !== 'client'">
|
||||
<img class="config-qr-img" :src="ConfigQrUrl()" loading="lazy" alt="Configuration QR Code">
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +207,7 @@ function ConfigQrUrl() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedInterface.Mode === 'server'" class="accordion-item">
|
||||
<div v-if="selectedInterface.Mode !== 'client'" class="accordion-item">
|
||||
<h2 class="accordion-header" id="headingConfig">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#collapseConfig" aria-expanded="false" aria-controls="collapseConfig">
|
||||
@@ -202,9 +225,9 @@ function ConfigQrUrl() {
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex-fill text-start">
|
||||
<button @click.prevent="download" type="button" class="btn btn-primary me-1">{{
|
||||
<button v-if="selectedInterface.Mode !== 'client'" @click.prevent="download" type="button" class="btn btn-primary me-1">{{
|
||||
$t('modals.peer-view.button-download') }}</button>
|
||||
<button @click.prevent="email" type="button" class="btn btn-primary me-1">{{
|
||||
<button v-if="selectedInterface.Mode !== 'client'" @click.prevent="email" type="button" class="btn btn-primary me-1">{{
|
||||
$t('modals.peer-view.button-email') }}</button>
|
||||
</div>
|
||||
<button @click.prevent="close" type="button" class="btn btn-secondary">{{ $t('general.close') }}</button>
|
||||
@@ -213,6 +236,14 @@ function ConfigQrUrl() {
|
||||
</template>
|
||||
</Modal></template>
|
||||
|
||||
<style>.config-qr-img {
|
||||
<style>
|
||||
.config-qr-img {
|
||||
max-width: 100%;
|
||||
}</style>
|
||||
}
|
||||
|
||||
.btn-switch-group .btn {
|
||||
border-width: 1px;
|
||||
padding: 5px;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,13 +34,15 @@ const title = computed(() => {
|
||||
})
|
||||
|
||||
const formData = ref(freshUser())
|
||||
const isSaving = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
const passwordWeak = computed(() => {
|
||||
return formData.value.Password && formData.value.Password.length > 0 && formData.value.Password.length < settings.Setting('MinPasswordLength')
|
||||
})
|
||||
|
||||
const formValid = computed(() => {
|
||||
if (formData.value.Source !== 'db') {
|
||||
if (!formData.value.AuthSources.some(s => s === 'db')) {
|
||||
return true // nothing to validate
|
||||
}
|
||||
if (props.userId !== '#NEW#' && passwordWeak.value) {
|
||||
@@ -68,7 +70,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
|
||||
} else { // fill existing userdata
|
||||
formData.value.Identifier = selectedUser.value.Identifier
|
||||
formData.value.Email = selectedUser.value.Email
|
||||
formData.value.Source = selectedUser.value.Source
|
||||
formData.value.AuthSources = selectedUser.value.AuthSources
|
||||
formData.value.IsAdmin = selectedUser.value.IsAdmin
|
||||
formData.value.Firstname = selectedUser.value.Firstname
|
||||
formData.value.Lastname = selectedUser.value.Lastname
|
||||
@@ -78,6 +80,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
|
||||
formData.value.Password = ""
|
||||
formData.value.Disabled = selectedUser.value.Disabled
|
||||
formData.value.Locked = selectedUser.value.Locked
|
||||
formData.value.PersistLocalChanges = selectedUser.value.PersistLocalChanges
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +92,8 @@ function close() {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (props.userId!=='#NEW#') {
|
||||
await users.UpdateUser(selectedUser.value.Identifier, formData.value)
|
||||
@@ -102,10 +107,14 @@ async function save() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function del() {
|
||||
if (isDeleting.value) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await users.DeleteUser(selectedUser.value.Identifier)
|
||||
close()
|
||||
@@ -115,6 +124,8 @@ async function del() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +134,7 @@ async function del() {
|
||||
<template>
|
||||
<Modal :title="title" :visible="visible" @close="close">
|
||||
<template #default>
|
||||
<fieldset v-if="formData.Source==='db'">
|
||||
<fieldset>
|
||||
<legend class="mt-4">{{ $t('modals.user-edit.header-general') }}</legend>
|
||||
<div v-if="props.userId==='#NEW#'" class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.user-edit.identifier.label') }}</label>
|
||||
@@ -131,16 +142,22 @@ async function del() {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.user-edit.source.label') }}</label>
|
||||
<input v-model="formData.Source" class="form-control" disabled="disabled" :placeholder="$t('modals.user-edit.source.placeholder')" type="text">
|
||||
<input v-model="formData.AuthSources" class="form-control" disabled="disabled" :placeholder="$t('modals.user-edit.source.placeholder')" type="text">
|
||||
</div>
|
||||
<div v-if="formData.Source==='db'" class="form-group">
|
||||
<div class="form-group" v-if="formData.AuthSources.some(s => s ==='db')">
|
||||
<label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label>
|
||||
<input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :class="{ 'is-invalid': passwordWeak, 'is-valid': formData.Password !== '' && !passwordWeak }" :placeholder="$t('modals.user-edit.password.placeholder')" type="password">
|
||||
<div class="invalid-feedback">{{ $t('modals.user-edit.password.too-weak') }}</div>
|
||||
<small v-if="props.userId!=='#NEW#'" id="passwordHelp" class="form-text text-muted">{{ $t('modals.user-edit.password.description') }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset v-if="formData.Source==='db'">
|
||||
<fieldset v-if="formData.AuthSources.some(s => s !=='db') && !formData.PersistLocalChanges">
|
||||
<legend class="mt-4">{{ $t('modals.user-edit.header-personal') }}</legend>
|
||||
<div class="alert alert-warning mt-3">
|
||||
{{ $t('modals.user-edit.sync-warning') }}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset v-if="!formData.AuthSources.some(s => s !=='db') || formData.PersistLocalChanges">
|
||||
<legend class="mt-4">{{ $t('modals.user-edit.header-personal') }}</legend>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.user-edit.email.label') }}</label>
|
||||
@@ -184,18 +201,28 @@ async function del() {
|
||||
<input v-model="formData.Locked" class="form-check-input" type="checkbox">
|
||||
<label class="form-check-label" >{{ $t('modals.user-edit.locked.label') }}</label>
|
||||
</div>
|
||||
<div class="form-check form-switch" v-if="formData.Source==='db'">
|
||||
<div class="form-check form-switch" v-if="!formData.AuthSources.some(s => s !=='db') || formData.PersistLocalChanges">
|
||||
<input v-model="formData.IsAdmin" checked="" class="form-check-input" type="checkbox">
|
||||
<label class="form-check-label">{{ $t('modals.user-edit.admin.label') }}</label>
|
||||
</div>
|
||||
<div class="form-check form-switch" v-if="formData.AuthSources.some(s => s !=='db')">
|
||||
<input v-model="formData.PersistLocalChanges" class="form-check-input" type="checkbox">
|
||||
<label class="form-check-label" >{{ $t('modals.user-edit.persist-local-changes.label') }}</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex-fill text-start">
|
||||
<button v-if="props.userId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
|
||||
<button v-if="props.userId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del" :disabled="isDeleting">
|
||||
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
{{ $t('general.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary me-1" type="button" @click.prevent="save" :disabled="!formValid">{{ $t('general.save') }}</button>
|
||||
<button class="btn btn-primary me-1" type="button" @click.prevent="save" :disabled="!formValid || isSaving">
|
||||
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
{{ $t('general.save') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
@@ -55,6 +55,8 @@ const title = computed(() => {
|
||||
})
|
||||
|
||||
const formData = ref(freshPeer())
|
||||
const isSaving = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
// functions
|
||||
|
||||
@@ -163,6 +165,8 @@ function close() {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (props.peerId !== '#NEW#') {
|
||||
await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value)
|
||||
@@ -171,26 +175,30 @@ async function save() {
|
||||
}
|
||||
close()
|
||||
} catch (e) {
|
||||
// console.log(e)
|
||||
notify({
|
||||
title: "Failed to save peer!",
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function del() {
|
||||
if (isDeleting.value) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await peers.DeletePeer(selectedPeer.value.Identifier)
|
||||
close()
|
||||
} catch (e) {
|
||||
// console.log(e)
|
||||
notify({
|
||||
title: "Failed to delete peer!",
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,10 +291,15 @@ async function del() {
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex-fill text-start">
|
||||
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{
|
||||
$t('general.delete') }}</button>
|
||||
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del" :disabled="isDeleting">
|
||||
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
{{ $t('general.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
|
||||
<button class="btn btn-primary me-1" type="button" @click.prevent="save" :disabled="isSaving">
|
||||
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
{{ $t('general.save') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export function base64_url_encode(input) {
|
||||
let output = btoa(input)
|
||||
output = output.replace('+', '.')
|
||||
output = output.replace('/', '_')
|
||||
output = output.replace('=', '-')
|
||||
output = output.replaceAll('+', '.')
|
||||
output = output.replaceAll('/', '_')
|
||||
output = output.replaceAll('=', '-')
|
||||
return output
|
||||
}
|
||||
@@ -4,7 +4,9 @@ export function freshInterface() {
|
||||
Disabled: false,
|
||||
DisplayName: "",
|
||||
Identifier: "",
|
||||
CreateDefaultPeer: false,
|
||||
Mode: "server",
|
||||
Backend: "local",
|
||||
|
||||
PublicKey: "",
|
||||
PrivateKey: "",
|
||||
@@ -52,6 +54,7 @@ export function freshPeer() {
|
||||
Identifier: "",
|
||||
DisplayName: "",
|
||||
UserIdentifier: "",
|
||||
UserDisplayName: "",
|
||||
InterfaceIdentifier: "",
|
||||
Disabled: false,
|
||||
ExpiresAt: null,
|
||||
@@ -134,7 +137,7 @@ export function freshUser() {
|
||||
Identifier: "",
|
||||
|
||||
Email: "",
|
||||
Source: "db",
|
||||
AuthSources: ["db"],
|
||||
IsAdmin: false,
|
||||
|
||||
Firstname: "",
|
||||
@@ -152,6 +155,8 @@ export function freshUser() {
|
||||
|
||||
ApiEnabled: false,
|
||||
|
||||
PersistLocalChanges: false,
|
||||
|
||||
PeerCount: 0,
|
||||
|
||||
// Internal values
|
||||
|
||||
@@ -4,12 +4,12 @@ export function ipToBigInt(ip) {
|
||||
// Check if it's an IPv4 address
|
||||
if (ip.includes(".")) {
|
||||
const addr = new Address4(ip)
|
||||
return addr.bigInteger()
|
||||
return addr.bigInt()
|
||||
}
|
||||
|
||||
// Otherwise, assume it's an IPv6 address
|
||||
const addr = new Address6(ip)
|
||||
return addr.bigInteger()
|
||||
return addr.bigInt()
|
||||
}
|
||||
|
||||
export function humanFileSize(size) {
|
||||
|
||||
86
frontend/src/helpers/websocket-wrapper.js
Normal file
86
frontend/src/helpers/websocket-wrapper.js
Normal file
@@ -0,0 +1,86 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import ru from './translations/ru.json';
|
||||
import uk from './translations/uk.json';
|
||||
import vi from './translations/vi.json';
|
||||
import zh from './translations/zh.json';
|
||||
import es from './translations/es.json';
|
||||
|
||||
import {createI18n} from "vue-i18n";
|
||||
|
||||
@@ -32,6 +33,7 @@ const i18n = createI18n({
|
||||
"uk": uk,
|
||||
"vi": vi,
|
||||
"zh": zh,
|
||||
"es": es,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
"audit": "Event Protokoll",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"keygen": "Schlüsselgenerator"
|
||||
"keygen": "Schlüsselgenerator",
|
||||
"calculator": "IP-Rechner"
|
||||
},
|
||||
"home": {
|
||||
"headline": "WireGuard® VPN Portal",
|
||||
@@ -102,7 +103,9 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Schnittstellenstatus für",
|
||||
"mode": "Modus",
|
||||
"backend": "Backend",
|
||||
"unknown-backend": "Unbekannt",
|
||||
"wrong-backend": "Ungültiges Backend, das lokale WireGuard Backend wird stattdessen verwendet!",
|
||||
"key": "Öffentlicher Schlüssel",
|
||||
"endpoint": "Öffentlicher Endpunkt",
|
||||
"port": "Port",
|
||||
@@ -115,6 +118,7 @@
|
||||
"dns": "DNS-Server",
|
||||
"mtu": "MTU",
|
||||
"default-keep-alive": "Standard Keepalive-Intervall",
|
||||
"default-dns": "Standard DNS-Server",
|
||||
"button-show-config": "Konfiguration anzeigen",
|
||||
"button-download-config": "Konfiguration herunterladen",
|
||||
"button-store-config": "Konfiguration für wg-quick speichern",
|
||||
@@ -125,6 +129,11 @@
|
||||
"button-add-peers": "Mehrere Peers hinzufügen",
|
||||
"button-show-peer": "Peer anzeigen",
|
||||
"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-expiring": "Peer läuft ab am",
|
||||
"peer-connected": "Verbunden",
|
||||
@@ -138,7 +147,7 @@
|
||||
"email": "E-Mail",
|
||||
"firstname": "Vorname",
|
||||
"lastname": "Nachname",
|
||||
"source": "Quelle",
|
||||
"sources": "Quellen",
|
||||
"peers": "Peers",
|
||||
"admin": "Admin"
|
||||
},
|
||||
@@ -149,6 +158,14 @@
|
||||
"button-add-user": "Benutzer hinzufügen",
|
||||
"button-show-user": "Benutzer anzeigen",
|
||||
"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-locked": "Konto ist gesperrt, Grund:",
|
||||
"admin": "Benutzer hat Administratorrechte",
|
||||
@@ -218,6 +235,16 @@
|
||||
"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-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": {
|
||||
@@ -256,6 +283,26 @@
|
||||
"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": {
|
||||
"user-view": {
|
||||
"headline": "Benutzerkonto:",
|
||||
@@ -331,7 +378,11 @@
|
||||
},
|
||||
"admin": {
|
||||
"label": "Ist Administrator"
|
||||
}
|
||||
},
|
||||
"persist-local-changes": {
|
||||
"label": "Lokale Änderungen speichern"
|
||||
},
|
||||
"sync-warning": "Um diesen synchronisierten Benutzer zu bearbeiten, aktivieren Sie die lokale Änderungsspeicherung. Andernfalls werden Ihre Änderungen bei der nächsten Synchronisierung überschrieben."
|
||||
},
|
||||
"interface-view": {
|
||||
"headline": "Konfiguration für Schnittstelle:"
|
||||
@@ -357,6 +408,11 @@
|
||||
"client": "Client-Modus",
|
||||
"any": "Unbekannter Modus"
|
||||
},
|
||||
"backend": {
|
||||
"label": "Schnittstellenbackend",
|
||||
"invalid-label": "Ursprüngliches Backend ist ungültig, das lokale WireGuard Backend wird stattdessen verwendet!",
|
||||
"local": "Lokales WireGuard Backend"
|
||||
},
|
||||
"display-name": {
|
||||
"label": "Anzeigename",
|
||||
"placeholder": "Der beschreibende Name für die Schnittstelle"
|
||||
@@ -417,6 +473,9 @@
|
||||
"disabled": {
|
||||
"label": "Schnittstelle deaktiviert"
|
||||
},
|
||||
"create-default-peer": {
|
||||
"label": "Peer für neue Benutzer automatisch erstellen"
|
||||
},
|
||||
"save-config": {
|
||||
"label": "wg-quick Konfiguration automatisch speichern"
|
||||
},
|
||||
@@ -454,6 +513,8 @@
|
||||
"section-config": "Konfiguration",
|
||||
"identifier": "Kennung",
|
||||
"ip": "IP-Adressen",
|
||||
"allowed-ip": "Erlaubte IP-Adressen",
|
||||
"extra-allowed-ip": "Serverseitig erlaubte IP-Adressen",
|
||||
"user": "Zugeordneter Benutzer",
|
||||
"notes": "Notizen",
|
||||
"expiry-status": "Läuft ab am",
|
||||
@@ -466,8 +527,11 @@
|
||||
"handshake": "Letzter Handshake",
|
||||
"connected-since": "Verbunden seit",
|
||||
"endpoint": "Endpunkt",
|
||||
"endpoint-key": "Öffentlicher Endpunkt-Schlüssel",
|
||||
"keepalive": "Persistentes Keepalive",
|
||||
"button-download": "Konfiguration herunterladen",
|
||||
"button-email": "Konfiguration per E-Mail senden"
|
||||
"button-email": "Konfiguration per E-Mail senden",
|
||||
"style-label": "Konfigurationsformat"
|
||||
},
|
||||
"peer-edit": {
|
||||
"headline-edit-peer": "Peer bearbeiten:",
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
"audit": "Audit Log",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"keygen": "Key Generator"
|
||||
"keygen": "Key Generator",
|
||||
"calculator": "IP Calculator"
|
||||
},
|
||||
"home": {
|
||||
"headline": "WireGuard® VPN Portal",
|
||||
@@ -102,7 +103,9 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Interface status for",
|
||||
"mode": "mode",
|
||||
"backend": "Backend",
|
||||
"unknown-backend": "Unknown",
|
||||
"wrong-backend": "Invalid backend, using local WireGuard backend instead!",
|
||||
"key": "Public Key",
|
||||
"endpoint": "Public Endpoint",
|
||||
"port": "Listening Port",
|
||||
@@ -115,6 +118,7 @@
|
||||
"dns": "DNS Servers",
|
||||
"mtu": "MTU",
|
||||
"default-keep-alive": "Default Keepalive Interval",
|
||||
"default-dns": "Default DNS Servers",
|
||||
"button-show-config": "Show configuration",
|
||||
"button-download-config": "Download configuration",
|
||||
"button-store-config": "Store configuration for wg-quick",
|
||||
@@ -125,6 +129,11 @@
|
||||
"button-add-peers": "Add Multiple Peers",
|
||||
"button-show-peer": "Show 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-expiring": "Peer is expiring at",
|
||||
"peer-connected": "Connected",
|
||||
@@ -138,7 +147,7 @@
|
||||
"email": "E-Mail",
|
||||
"firstname": "Firstname",
|
||||
"lastname": "Lastname",
|
||||
"source": "Source",
|
||||
"sources": "Sources",
|
||||
"peers": "Peers",
|
||||
"admin": "Admin"
|
||||
},
|
||||
@@ -149,6 +158,14 @@
|
||||
"button-add-user": "Add User",
|
||||
"button-show-user": "Show 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-locked": "Account is locked, reason:",
|
||||
"admin": "User has administrator privileges",
|
||||
@@ -218,6 +235,16 @@
|
||||
"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-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": {
|
||||
@@ -256,6 +283,26 @@
|
||||
"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": {
|
||||
"user-view": {
|
||||
"headline": "User Account:",
|
||||
@@ -331,7 +378,11 @@
|
||||
},
|
||||
"admin": {
|
||||
"label": "Is Admin"
|
||||
}
|
||||
},
|
||||
"persist-local-changes": {
|
||||
"label": "Persist local changes"
|
||||
},
|
||||
"sync-warning": "To modify this synchronized user, enable local change persistence. Otherwise, your changes will be overwritten during the next synchronization."
|
||||
},
|
||||
"interface-view": {
|
||||
"headline": "Config for Interface:"
|
||||
@@ -357,6 +408,11 @@
|
||||
"client": "Client Mode",
|
||||
"any": "Unknown Mode"
|
||||
},
|
||||
"backend": {
|
||||
"label": "Interface Backend",
|
||||
"invalid-label": "Original backend is no longer available, using local WireGuard backend instead!",
|
||||
"local": "Local WireGuard Backend"
|
||||
},
|
||||
"display-name": {
|
||||
"label": "Display Name",
|
||||
"placeholder": "The descriptive name for the interface"
|
||||
@@ -417,6 +473,9 @@
|
||||
"disabled": {
|
||||
"label": "Interface Disabled"
|
||||
},
|
||||
"create-default-peer": {
|
||||
"label": "Create default peer for new users"
|
||||
},
|
||||
"save-config": {
|
||||
"label": "Automatically save wg-quick config"
|
||||
},
|
||||
@@ -455,6 +514,8 @@
|
||||
"section-config": "Configuration",
|
||||
"identifier": "Identifier",
|
||||
"ip": "IP Addresses",
|
||||
"allowed-ip": "Allowed IP Addresses",
|
||||
"extra-allowed-ip": "Server Side Allowed IP Addresses",
|
||||
"user": "Associated User",
|
||||
"notes": "Notes",
|
||||
"expiry-status": "Expires At",
|
||||
@@ -467,8 +528,11 @@
|
||||
"handshake": "Last Handshake",
|
||||
"connected-since": "Connected since",
|
||||
"endpoint": "Endpoint",
|
||||
"endpoint-key": "Endpoint Public Key",
|
||||
"keepalive": "Persistent Keepalive",
|
||||
"button-download": "Download configuration",
|
||||
"button-email": "Send configuration via E-Mail"
|
||||
"button-email": "Send configuration via E-Mail",
|
||||
"style-label": "Configuration Style"
|
||||
},
|
||||
"peer-edit": {
|
||||
"headline-edit-peer": "Edit peer:",
|
||||
|
||||
635
frontend/src/lang/translations/es.json
Normal file
635
frontend/src/lang/translations/es.json
Normal file
@@ -0,0 +1,635 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "État de l'interface pour",
|
||||
"mode": "mode",
|
||||
"backend": "backend",
|
||||
"key": "Clé publique",
|
||||
"endpoint": "Point de terminaison public",
|
||||
"port": "Port d'écoute",
|
||||
@@ -137,7 +137,7 @@
|
||||
"email": "E-mail",
|
||||
"firstname": "Prénom",
|
||||
"lastname": "Nom",
|
||||
"source": "Source",
|
||||
"sources": "Sources",
|
||||
"peers": "Pairs",
|
||||
"admin": "Admin"
|
||||
},
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "인터페이스 상태:",
|
||||
"mode": "모드",
|
||||
"backend": "백엔드",
|
||||
"key": "공개 키",
|
||||
"endpoint": "공개 엔드포인트",
|
||||
"port": "수신 포트",
|
||||
@@ -136,7 +136,7 @@
|
||||
"email": "이메일",
|
||||
"firstname": "이름",
|
||||
"lastname": "성",
|
||||
"source": "소스",
|
||||
"sources": "소스",
|
||||
"peers": "피어",
|
||||
"admin": "관리자"
|
||||
},
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Status da interface para",
|
||||
"mode": "modo",
|
||||
"mode": "backend",
|
||||
"key": "Chave Pública",
|
||||
"endpoint": "Endpoint Público",
|
||||
"port": "Porta de Escuta",
|
||||
@@ -137,7 +137,7 @@
|
||||
"email": "E-Mail",
|
||||
"firstname": "Primeiro Nome",
|
||||
"lastname": "Último Nome",
|
||||
"source": "Fonte",
|
||||
"sources": "Fonte",
|
||||
"peers": "Peers",
|
||||
"admin": "Administrador"
|
||||
},
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"label": "Пароль",
|
||||
"placeholder": "Пожалуйста, введите ваш пароль"
|
||||
},
|
||||
"button": "Войти"
|
||||
"button": "Войти",
|
||||
"button-webauthn": "Использовать Passkey"
|
||||
},
|
||||
"menu": {
|
||||
"home": "Главная",
|
||||
@@ -37,8 +38,12 @@
|
||||
"users": "Пользователи",
|
||||
"lang": "Сменить язык",
|
||||
"profile": "Мой профиль",
|
||||
"settings": "Настройки",
|
||||
"audit": "Журнал аудита",
|
||||
"login": "Вход",
|
||||
"logout": "Выход"
|
||||
"logout": "Выход",
|
||||
"keygen": "Генератор ключей",
|
||||
"calculator": "Калькулятор IP-адресов"
|
||||
},
|
||||
"home": {
|
||||
"headline": "Портал VPN WireGuard®",
|
||||
@@ -99,7 +104,9 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Статус интерфейса для",
|
||||
"mode": "режим",
|
||||
"backend": "бэкэнд",
|
||||
"unknown-backend": "Неизвестно",
|
||||
"wrong-backend": "Неверный бэкэнд, вместо него используется локальный сервер WireGuard!",
|
||||
"key": "Публичный ключ",
|
||||
"endpoint": "Публичная конечная точка",
|
||||
"port": "Порт прослушивания",
|
||||
@@ -112,6 +119,7 @@
|
||||
"dns": "DNS-серверы",
|
||||
"mtu": "MTU",
|
||||
"default-keep-alive": "Интервал поддержания активности по умолчанию",
|
||||
"default-dns": "DNS-сервера по-умолчанию",
|
||||
"button-show-config": "Показать конфигурацию",
|
||||
"button-download-config": "Скачать конфигурацию",
|
||||
"button-store-config": "Сохранить конфигурацию для wg-quick",
|
||||
@@ -135,7 +143,7 @@
|
||||
"email": "Электронная почта",
|
||||
"firstname": "Имя",
|
||||
"lastname": "Фамилия",
|
||||
"source": "Источник",
|
||||
"sources": "Источник",
|
||||
"peers": "Пиры",
|
||||
"admin": "Админ"
|
||||
},
|
||||
@@ -168,6 +176,121 @@
|
||||
"button-show-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": {
|
||||
"user-view": {
|
||||
"headline": "Учетная запись пользователя:",
|
||||
@@ -180,6 +303,7 @@
|
||||
"lastname": "Фамилия",
|
||||
"phone": "Номер телефона",
|
||||
"department": "Отдел",
|
||||
"api-enabled": "API",
|
||||
"disabled": "Учетная запись отключена",
|
||||
"locked": "Учетная запись заблокирована",
|
||||
"no-peers": "У пользователя нет связанных пиров.",
|
||||
@@ -207,7 +331,8 @@
|
||||
"password": {
|
||||
"label": "Пароль",
|
||||
"placeholder": "Надежный пароль",
|
||||
"description": "Оставьте это поле пустым, чтобы сохранить текущий пароль."
|
||||
"description": "Оставьте это поле пустым, чтобы сохранить текущий пароль.",
|
||||
"too-weak": "Пароль слишком простой. Используйте более сложный пароль."
|
||||
},
|
||||
"email": {
|
||||
"label": "Электронная почта",
|
||||
@@ -267,6 +392,11 @@
|
||||
"client": "Режим клиента",
|
||||
"any": "Неизвестный режим"
|
||||
},
|
||||
"backend": {
|
||||
"label": "Бэкэнд интерфейса",
|
||||
"invalid-label": "Оригинальный бэкэнд больше недоступн, вместо нее используется локальная WireGuard-бэкэнд!",
|
||||
"local": "Локальный WireGuard-бэкэнд"
|
||||
},
|
||||
"display-name": {
|
||||
"label": "Отображаемое имя",
|
||||
"placeholder": "Описательное имя для интерфейса"
|
||||
@@ -364,6 +494,8 @@
|
||||
"section-config": "Конфигурация",
|
||||
"identifier": "Идентификатор",
|
||||
"ip": "IP-адреса",
|
||||
"allowed-ip": "Разрешённые IP-адреса",
|
||||
"extra-allowed-ip": "Разрешённые IP-адреса на стороне сервера",
|
||||
"user": "Связанный пользователь",
|
||||
"notes": "Заметки",
|
||||
"expiry-status": "Истекает в",
|
||||
@@ -376,8 +508,10 @@
|
||||
"handshake": "Последнее рукопожатие",
|
||||
"connected-since": "Подключен с",
|
||||
"endpoint": "Конечная точка",
|
||||
"endpoint-key": "Публичный ключ конечной точки",
|
||||
"button-download": "Скачать конфигурацию",
|
||||
"button-email": "Отправить конфигурацию по электронной почте"
|
||||
"button-email": "Отправить конфигурацию по электронной почте",
|
||||
"style-label": "Вид конфигурации"
|
||||
},
|
||||
"peer-edit": {
|
||||
"headline-edit-peer": "Редактировать пира:",
|
||||
@@ -399,7 +533,8 @@
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Приватный ключ",
|
||||
"placeholder": "Приватный ключ"
|
||||
"placeholder": "Приватный ключ",
|
||||
"help": "Закрытый ключ надежно хранится на сервере. Если у пользователя уже есть копия, вы можете не указывать это поле. Сервер работает исключительно с открытым ключом клиента."
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Публичный ключ",
|
||||
@@ -431,61 +566,61 @@
|
||||
"description": "Эти IP-адреса будут добавлены в удаленный интерфейс WireGuard как разрешенные IP-адреса."
|
||||
},
|
||||
"dns": {
|
||||
"label": "DNS Server",
|
||||
"placeholder": "The DNS servers that should be used"
|
||||
"label": "DNS-сервер",
|
||||
"placeholder": "Используемые DNS-серверы"
|
||||
},
|
||||
"dns-search": {
|
||||
"label": "DNS Search Domains",
|
||||
"placeholder": "DNS search prefixes"
|
||||
"label": "Поисковые домены DNS",
|
||||
"placeholder": "Префиксы поиска DNS"
|
||||
},
|
||||
"keep-alive": {
|
||||
"label": "Keep Alive Interval",
|
||||
"placeholder": "Persistent Keepalive (0 = default)"
|
||||
"label": "Интервал поддержания активности",
|
||||
"placeholder": "Постоянное поддержание активности (0 = значение по умолчанию)"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "The client MTU (0 = keep default)"
|
||||
"placeholder": "MTU клиента (0 = использовать значение по умолчанию)"
|
||||
},
|
||||
"pre-up": {
|
||||
"label": "Pre-Up",
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
"placeholder": "Одна или несколько команд bash, разделенных ;"
|
||||
},
|
||||
"post-up": {
|
||||
"label": "Post-Up",
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
"placeholder": "Одна или несколько команд bash, разделенных ;"
|
||||
},
|
||||
"pre-down": {
|
||||
"label": "Pre-Down",
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
"placeholder": "Одна или несколько команд bash, разделенных ;"
|
||||
},
|
||||
"post-down": {
|
||||
"label": "Post-Down",
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
"placeholder": "Одна или несколько команд bash, разделенных ;"
|
||||
},
|
||||
"disabled": {
|
||||
"label": "Peer Disabled"
|
||||
"label": "Узел отключен"
|
||||
},
|
||||
"ignore-global": {
|
||||
"label": "Ignore global settings"
|
||||
"label": "Игнорировать глобальные настройки"
|
||||
},
|
||||
"expires-at": {
|
||||
"label": "Expiry date"
|
||||
"label": "Дата истечения срока действия"
|
||||
}
|
||||
},
|
||||
"peer-multi-create": {
|
||||
"headline-peer": "Create multiple peers",
|
||||
"headline-endpoint": "Create multiple endpoints",
|
||||
"headline-peer": "Создать несколько узлов",
|
||||
"headline-endpoint": "Создать несколько конечных точек",
|
||||
"identifiers": {
|
||||
"label": "User Identifiers",
|
||||
"placeholder": "User Identifiers",
|
||||
"description": "A user identifier (the username) for which a peer should be created."
|
||||
"label": "Идентификаторы пользователей",
|
||||
"placeholder": "Идентификаторы пользователей",
|
||||
"description": "Идентификатор пользователя (имя пользователя), для которого узел будет создан."
|
||||
},
|
||||
"prefix": {
|
||||
"headline-peer": "Peer:",
|
||||
"headline-endpoint": "Endpoint:",
|
||||
"label": "Display Name Prefix",
|
||||
"placeholder": "The prefix",
|
||||
"description": "A prefix that is added to the peers display name."
|
||||
"headline-peer": "Узел:",
|
||||
"headline-endpoint": "Конечная точка:",
|
||||
"label": "Префикс отображаемого имени",
|
||||
"placeholder": "Префикс",
|
||||
"description": "Префикс будет добавлен к отображаемому имени узла."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Статус інтерфейсу для",
|
||||
"mode": "режим",
|
||||
"backend": "бекенд",
|
||||
"key": "Публічний ключ",
|
||||
"endpoint": "Публічна кінцева точка",
|
||||
"port": "Порт прослуховування",
|
||||
@@ -135,7 +135,7 @@
|
||||
"email": "E-Mail",
|
||||
"firstname": "Ім'я",
|
||||
"lastname": "Прізвище",
|
||||
"source": "Джерело",
|
||||
"sources": "Джерело",
|
||||
"peers": "Піри",
|
||||
"admin": "Адміністратор"
|
||||
},
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Trạng thái giao diện cho",
|
||||
"mode": "chế độ",
|
||||
"backend": "phần sau",
|
||||
"key": "Khóa Công khai",
|
||||
"endpoint": "Điểm cuối Công khai",
|
||||
"port": "Cổng Nghe",
|
||||
@@ -134,7 +134,7 @@
|
||||
"email": "E-Mail",
|
||||
"firstname": "Tên",
|
||||
"lastname": "Họ",
|
||||
"source": "Nguồn",
|
||||
"sources": "Nguồn",
|
||||
"peers": "Peers",
|
||||
"admin": "Quản trị viên"
|
||||
},
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "接口状态",
|
||||
"mode": "模式",
|
||||
"backend": "后端",
|
||||
"key": "公钥",
|
||||
"endpoint": "公开节点",
|
||||
"port": "监听端口",
|
||||
@@ -134,7 +134,7 @@
|
||||
"email": "电子邮件",
|
||||
"firstname": "名",
|
||||
"lastname": "姓",
|
||||
"source": "来源",
|
||||
"sources": "来源",
|
||||
"peers": "节点",
|
||||
"admin": "管理员"
|
||||
},
|
||||
|
||||
@@ -72,6 +72,14 @@ const router = createRouter({
|
||||
// this generates a separate chunk (About.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
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",
|
||||
@@ -122,7 +130,7 @@ router.beforeEach(async (to) => {
|
||||
}
|
||||
|
||||
// redirect to login page if not logged in and trying to access a restricted page
|
||||
const publicPages = ['/', '/login', '/key-generator']
|
||||
const publicPages = ['/', '/login', '/key-generator', '/ip-calculator']
|
||||
const authRequired = !publicPages.includes(to.path)
|
||||
|
||||
if (authRequired && !auth.IsAuthenticated) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
import { apiWrapper } from '@/helpers/fetch-wrapper'
|
||||
import { websocketWrapper } from '@/helpers/websocket-wrapper'
|
||||
import router from '../router'
|
||||
import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser';
|
||||
import {base64_url_encode} from "@/helpers/encoding";
|
||||
@@ -295,9 +296,11 @@ export const authStore = defineStore('auth',{
|
||||
}
|
||||
}
|
||||
localStorage.setItem('user', JSON.stringify(this.user))
|
||||
websocketWrapper.connect()
|
||||
} else {
|
||||
this.user = null
|
||||
localStorage.removeItem('user')
|
||||
websocketWrapper.disconnect()
|
||||
}
|
||||
},
|
||||
setWebAuthnCredentials(credentials) {
|
||||
|
||||
@@ -14,6 +14,7 @@ export const interfaceStore = defineStore('interfaces', {
|
||||
configuration: "",
|
||||
selected: "",
|
||||
fetching: false,
|
||||
trafficStats: {},
|
||||
}),
|
||||
getters: {
|
||||
Count: (state) => state.interfaces.length,
|
||||
@@ -24,6 +25,9 @@ export const interfaceStore = defineStore('interfaces', {
|
||||
},
|
||||
GetSelected: (state) => state.interfaces.find((i) => i.Identifier === state.selected) || state.interfaces[0],
|
||||
isFetching: (state) => state.fetching,
|
||||
TrafficStats: (state) => {
|
||||
return (state.selected in state.trafficStats) ? state.trafficStats[state.selected] : { Received: 0, Transmitted: 0 }
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setInterfaces(interfaces) {
|
||||
@@ -34,6 +38,14 @@ export const interfaceStore = defineStore('interfaces', {
|
||||
this.selected = ""
|
||||
}
|
||||
this.fetching = false
|
||||
this.trafficStats = {}
|
||||
},
|
||||
updateInterfaceTrafficStats(interfaceStats) {
|
||||
const id = interfaceStats.EntityId;
|
||||
this.trafficStats[id] = {
|
||||
Received: interfaceStats.BytesReceived,
|
||||
Transmitted: interfaceStats.BytesTransmitted,
|
||||
};
|
||||
},
|
||||
async LoadInterfaces() {
|
||||
this.fetching = true
|
||||
@@ -115,6 +127,7 @@ export const interfaceStore = defineStore('interfaces', {
|
||||
return apiWrapper.post(`${baseUrl}/new`, formData)
|
||||
.then(iface => {
|
||||
this.interfaces.push(iface)
|
||||
this.selected = iface.Identifier
|
||||
this.fetching = false
|
||||
})
|
||||
.catch(error => {
|
||||
|
||||
@@ -23,6 +23,7 @@ export const peerStore = defineStore('peers', {
|
||||
fetching: false,
|
||||
sortKey: 'IsConnected', // Default sort key
|
||||
sortOrder: -1, // 1 for ascending, -1 for descending
|
||||
trafficStats: {},
|
||||
}),
|
||||
getters: {
|
||||
Find: (state) => {
|
||||
@@ -76,6 +77,9 @@ export const peerStore = defineStore('peers', {
|
||||
Statistics: (state) => {
|
||||
return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats()
|
||||
},
|
||||
TrafficStats: (state) => {
|
||||
return (id) => (id in state.trafficStats) ? state.trafficStats[id] : { Received: 0, Transmitted: 0 }
|
||||
},
|
||||
hasStatistics: (state) => state.statsEnabled,
|
||||
|
||||
},
|
||||
@@ -111,6 +115,7 @@ export const peerStore = defineStore('peers', {
|
||||
this.peers = peers
|
||||
this.calculatePages()
|
||||
this.fetching = false
|
||||
this.trafficStats = {}
|
||||
},
|
||||
setPeer(peer) {
|
||||
this.peer = peer
|
||||
@@ -126,9 +131,22 @@ export const peerStore = defineStore('peers', {
|
||||
if (!statsResponse) {
|
||||
this.stats = {}
|
||||
this.statsEnabled = false
|
||||
this.trafficStats = {}
|
||||
} else {
|
||||
this.stats = statsResponse.Stats
|
||||
this.statsEnabled = statsResponse.Enabled
|
||||
}
|
||||
this.stats = statsResponse.Stats
|
||||
this.statsEnabled = statsResponse.Enabled
|
||||
},
|
||||
updatePeerTrafficStats(peerStats) {
|
||||
const id = peerStats.EntityId;
|
||||
this.trafficStats[id] = {
|
||||
Received: peerStats.BytesReceived,
|
||||
Transmitted: peerStats.BytesTransmitted,
|
||||
};
|
||||
},
|
||||
async Reset() {
|
||||
this.setPeers([])
|
||||
this.setStats(undefined)
|
||||
},
|
||||
async PreparePeer(interfaceId) {
|
||||
return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/prepare`)
|
||||
@@ -142,8 +160,8 @@ export const peerStore = defineStore('peers', {
|
||||
})
|
||||
})
|
||||
},
|
||||
async MailPeerConfig(linkOnly, ids) {
|
||||
return apiWrapper.post(`${baseUrl}/config-mail`, {
|
||||
async MailPeerConfig(linkOnly, style, ids) {
|
||||
return apiWrapper.post(`${baseUrl}/config-mail?style=${style}`, {
|
||||
Identifiers: ids,
|
||||
LinkOnly: linkOnly
|
||||
})
|
||||
@@ -158,8 +176,8 @@ export const peerStore = defineStore('peers', {
|
||||
throw new Error(error)
|
||||
})
|
||||
},
|
||||
async LoadPeerConfig(id) {
|
||||
return apiWrapper.get(`${baseUrl}/config/${base64_url_encode(id)}`)
|
||||
async LoadPeerConfig(id, style) {
|
||||
return apiWrapper.get(`${baseUrl}/config/${base64_url_encode(id)}?style=${style}`)
|
||||
.then(this.setPeerConfig)
|
||||
.catch(error => {
|
||||
this.configuration = ""
|
||||
@@ -186,10 +204,10 @@ export const peerStore = defineStore('peers', {
|
||||
async LoadStats(interfaceId) {
|
||||
// if no interfaceId is given, use the currently selected interface
|
||||
if (!interfaceId) {
|
||||
interfaceId = interfaceStore().GetSelected.Identifier
|
||||
if (!interfaceId) {
|
||||
return // no interface, nothing to load
|
||||
if (!interfaceStore().GetSelected || !interfaceStore().GetSelected.Identifier) {
|
||||
return // no interface, nothing to load
|
||||
}
|
||||
interfaceId = interfaceStore().GetSelected.Identifier
|
||||
}
|
||||
this.fetching = true
|
||||
|
||||
@@ -217,6 +235,73 @@ export const peerStore = defineStore('peers', {
|
||||
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) {
|
||||
this.fetching = true
|
||||
return apiWrapper.put(`${baseUrl}/${base64_url_encode(id)}`, formData)
|
||||
@@ -260,10 +345,10 @@ export const peerStore = defineStore('peers', {
|
||||
async LoadPeers(interfaceId) {
|
||||
// if no interfaceId is given, use the currently selected interface
|
||||
if (!interfaceId) {
|
||||
interfaceId = interfaceStore().GetSelected.Identifier
|
||||
if (!interfaceId) {
|
||||
if (!interfaceStore().GetSelected || !interfaceStore().GetSelected.Identifier) {
|
||||
return // no interface, nothing to load
|
||||
}
|
||||
interfaceId = interfaceStore().GetSelected.Identifier
|
||||
}
|
||||
this.fetching = true
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import {apiWrapper} from "@/helpers/fetch-wrapper";
|
||||
import {notify} from "@kyvg/vue3-notification";
|
||||
import {authStore} from "@/stores/auth";
|
||||
import {peerStore} from "@/stores/peers";
|
||||
import { base64_url_encode } from '@/helpers/encoding';
|
||||
import {freshStats} from "@/helpers/models";
|
||||
import { ipToBigInt } from '@/helpers/utils';
|
||||
@@ -151,6 +152,17 @@ export const profileStore = defineStore('profile', {
|
||||
})
|
||||
})
|
||||
},
|
||||
async changePassword(formData) {
|
||||
this.fetching = true
|
||||
let currentUser = authStore().user.Identifier
|
||||
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/change-password`, formData)
|
||||
.then(this.fetching = false)
|
||||
.catch(error => {
|
||||
this.fetching = false;
|
||||
console.log("Failed to change password for ", currentUser, ": ", error);
|
||||
throw new Error(error);
|
||||
});
|
||||
},
|
||||
async LoadPeers() {
|
||||
this.fetching = true
|
||||
let currentUser = authStore().user.Identifier
|
||||
@@ -207,5 +219,18 @@ export const profileStore = defineStore('profile', {
|
||||
})
|
||||
})
|
||||
},
|
||||
async BulkDelete(ids) {
|
||||
this.fetching = true
|
||||
const peers = peerStore()
|
||||
return peers.BulkDelete(ids)
|
||||
.then(() => {
|
||||
this.peers = this.peers.filter(p => !ids.includes(p.Identifier))
|
||||
this.fetching = false
|
||||
})
|
||||
.catch(error => {
|
||||
this.fetching = false
|
||||
throw new Error(error)
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -142,5 +142,140 @@ export const userStore = defineStore('users', {
|
||||
})
|
||||
})
|
||||
},
|
||||
async BulkDelete(ids) {
|
||||
this.fetching = true
|
||||
return apiWrapper.post(`${baseUrl}/bulk-delete`, { Identifiers: ids })
|
||||
.then(() => {
|
||||
this.users = this.users.filter(u => !ids.includes(u.Identifier))
|
||||
this.fetching = false
|
||||
notify({
|
||||
title: "Users deleted",
|
||||
text: "Selected users have been deleted!",
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
this.fetching = false
|
||||
console.log("Failed to delete users: ", error)
|
||||
notify({
|
||||
title: "Backend Connection Failure",
|
||||
text: "Failed to delete selected users!",
|
||||
type: 'error',
|
||||
})
|
||||
throw new Error(error)
|
||||
})
|
||||
},
|
||||
async BulkEnable(ids) {
|
||||
this.fetching = true
|
||||
return apiWrapper.post(`${baseUrl}/bulk-enable`, { Identifiers: ids })
|
||||
.then(() => {
|
||||
this.users.forEach(u => {
|
||||
if (ids.includes(u.Identifier)) {
|
||||
u.Disabled = false
|
||||
u.DisabledReason = ""
|
||||
}
|
||||
})
|
||||
this.fetching = false
|
||||
notify({
|
||||
title: "Users enabled",
|
||||
text: "Selected users have been enabled!",
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
this.fetching = false
|
||||
console.log("Failed to enable users: ", error)
|
||||
notify({
|
||||
title: "Backend Connection Failure",
|
||||
text: "Failed to enable selected users!",
|
||||
type: 'error',
|
||||
})
|
||||
throw new Error(error)
|
||||
})
|
||||
},
|
||||
async BulkDisable(ids, reason) {
|
||||
this.fetching = true
|
||||
return apiWrapper.post(`${baseUrl}/bulk-disable`, { Identifiers: ids, Reason: reason })
|
||||
.then(() => {
|
||||
this.users.forEach(u => {
|
||||
if (ids.includes(u.Identifier)) {
|
||||
u.Disabled = true
|
||||
u.DisabledReason = reason
|
||||
}
|
||||
})
|
||||
this.fetching = false
|
||||
notify({
|
||||
title: "Users disabled",
|
||||
text: "Selected users have been disabled!",
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
this.fetching = false
|
||||
console.log("Failed to disable users: ", error)
|
||||
notify({
|
||||
title: "Backend Connection Failure",
|
||||
text: "Failed to disable selected users!",
|
||||
type: 'error',
|
||||
})
|
||||
throw new Error(error)
|
||||
})
|
||||
},
|
||||
async BulkLock(ids, reason) {
|
||||
this.fetching = true
|
||||
return apiWrapper.post(`${baseUrl}/bulk-lock`, { Identifiers: ids, Reason: reason })
|
||||
.then(() => {
|
||||
this.users.forEach(u => {
|
||||
if (ids.includes(u.Identifier)) {
|
||||
u.Locked = true
|
||||
u.LockedReason = reason
|
||||
}
|
||||
})
|
||||
this.fetching = false
|
||||
notify({
|
||||
title: "Users locked",
|
||||
text: "Selected users have been locked!",
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
this.fetching = false
|
||||
console.log("Failed to lock users: ", error)
|
||||
notify({
|
||||
title: "Backend Connection Failure",
|
||||
text: "Failed to lock selected users!",
|
||||
type: 'error',
|
||||
})
|
||||
throw new Error(error)
|
||||
})
|
||||
},
|
||||
async BulkUnlock(ids) {
|
||||
this.fetching = true
|
||||
return apiWrapper.post(`${baseUrl}/bulk-unlock`, { Identifiers: ids })
|
||||
.then(() => {
|
||||
this.users.forEach(u => {
|
||||
if (ids.includes(u.Identifier)) {
|
||||
u.Locked = false
|
||||
u.LockedReason = ""
|
||||
}
|
||||
})
|
||||
this.fetching = false
|
||||
notify({
|
||||
title: "Users unlocked",
|
||||
text: "Selected users have been unlocked!",
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
this.fetching = false
|
||||
console.log("Failed to unlock users: ", error)
|
||||
notify({
|
||||
title: "Backend Connection Failure",
|
||||
text: "Failed to unlock selected users!",
|
||||
type: 'error',
|
||||
})
|
||||
throw new Error(error)
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ onMounted(async () => {
|
||||
<div class="form-group d-inline">
|
||||
<div class="input-group mb-3">
|
||||
<input v-model="audit.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="audit.afterPageSizeChange">
|
||||
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
|
||||
<button class="btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,21 +13,21 @@ const auth = authStore()
|
||||
<p class="lead">{{ $t('home.abstract') }}</p>
|
||||
|
||||
|
||||
<div class="bg-light p-5" v-if="auth.IsAuthenticated">
|
||||
<div class="card border-secondary p-5" v-if="auth.IsAuthenticated">
|
||||
<h2 class="display-5">{{ $t('home.profiles.headline') }}</h2>
|
||||
<p class="lead">{{ $t('home.profiles.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
<p>{{ $t('home.profiles.content') }}</p>
|
||||
<p class="card-text">{{ $t('home.profiles.content') }}</p>
|
||||
<p class="lead">
|
||||
<RouterLink :to="{ name: 'profile' }" class="btn btn-primary btn-lg">{{ $t('home.profiles.button') }}</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-light p-5 mt-4" v-if="auth.IsAuthenticated && auth.IsAdmin">
|
||||
<div class="card border-secondary p-5 mt-4" v-if="auth.IsAuthenticated && auth.IsAdmin">
|
||||
<h2 class="display-5">{{ $t('home.admin.headline') }}</h2>
|
||||
<p class="lead">{{ $t('home.admin.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
<p>{{ $t('home.admin.content') }}</p>
|
||||
<p class="card-text">{{ $t('home.admin.content') }}</p>
|
||||
<p class="lead">
|
||||
<RouterLink :to="{ name: 'interfaces' }" class="btn btn-primary btn-lg me-2">{{ $t('home.admin.button-admin') }}
|
||||
</RouterLink>
|
||||
|
||||
139
frontend/src/views/IPCalculatorView.vue
Normal file
139
frontend/src/views/IPCalculatorView.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup>
|
||||
|
||||
import {ref, watch, computed} from "vue";
|
||||
import isCidr from "is-cidr";
|
||||
import {isIP} from "is-ip";
|
||||
import {excludeCidr} from "cidr-tools";
|
||||
import {useI18n} from 'vue-i18n';
|
||||
|
||||
const allowedIp = ref("")
|
||||
const dissallowedIp = ref("")
|
||||
const privateIP = ref("10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16")
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const errorAllowed = ref("")
|
||||
const errorDissallowed = ref("")
|
||||
|
||||
/**
|
||||
* Validate a comma-separated list of IP and/or CIDR addresses.
|
||||
* @function validateIpAndCidrList
|
||||
* @param {string} value - Comma-separated string (e.g. "10.0.0.0/8, 192.168.0.1")
|
||||
* @returns {true|string} Returns true if all values are valid, otherwise an error message.
|
||||
*/
|
||||
function validateIpAndCidrList(value) {
|
||||
const list = value.split(",").map(v => v.trim()).filter(Boolean);
|
||||
if (list.length === 0) {
|
||||
return t('calculator.allowed-ip.empty');
|
||||
}
|
||||
|
||||
for (const addr of list) {
|
||||
if (!isIP(addr) && !isCidr(addr)) {
|
||||
return t('calculator.dissallowed-ip.invalid', {addr});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watcher that validates allowed IPs input in real-time.
|
||||
* Updates `errorAllowed` whenever `allowedIp` changes.
|
||||
*/
|
||||
watch(allowedIp, (newValue) => {
|
||||
const result = validateIpAndCidrList(newValue);
|
||||
errorAllowed.value = result === true ? "" : result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Watcher that validates disallowed IPs input in real-time.
|
||||
* Updates `errorDissallowed` whenever `dissallowedIp` changes.
|
||||
*/
|
||||
watch(dissallowedIp, (newValue) => {
|
||||
if (!allowedIp.value || allowedIp.value.trim() === "") {
|
||||
allowedIp.value = "0.0.0.0/0";
|
||||
}
|
||||
const result = validateIpAndCidrList(newValue);
|
||||
errorDissallowed.value = result === true ? "" : result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Dynamically computes the resulting "Allowed IPs" list
|
||||
* by excluding the disallowed ranges from the allowed ranges.
|
||||
* @constant
|
||||
* @type {ComputedRef<string>}
|
||||
* @returns {string} A comma-separated string of resulting CIDR blocks.
|
||||
*/
|
||||
const newAllowedIp = computed(() => {
|
||||
if (errorAllowed.value || errorDissallowed.value) return "";
|
||||
|
||||
try {
|
||||
const allowedList = allowedIp.value.split(",").map(v => v.trim()).filter(Boolean);
|
||||
const disallowedList = dissallowedIp.value.split(",").map(v => v.trim()).filter(Boolean);
|
||||
|
||||
const result = excludeCidr(allowedList, disallowedList);
|
||||
|
||||
return result.join(", ");
|
||||
} catch (e) {
|
||||
console.error("Allowed IPs calculation error:", e);
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Append private IP ranges to disallowed IPs.
|
||||
* If any already exist, they are preserved and new ones are appended only if not present.
|
||||
* @function addPrivateIPs
|
||||
*/
|
||||
function addPrivateIPs() {
|
||||
const privateList = privateIP.value.split(",").map(v => v.trim());
|
||||
const currentList = dissallowedIp.value.split(",").map(v => v.trim()).filter(Boolean);
|
||||
|
||||
const combined = Array.from(new Set([...currentList, ...privateList]));
|
||||
dissallowedIp.value = combined.join(", ");
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>{{ $t('calculator.headline') }}</h1>
|
||||
</div>
|
||||
|
||||
<p class="lead">{{ $t('calculator.abstract') }}</p>
|
||||
|
||||
<div class="mt-4 row">
|
||||
<div class="col-12 col-lg-5">
|
||||
<fieldset>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('calculator.allowed-ip.label') }}</label>
|
||||
<input class="form-control" v-model="allowedIp" :placeholder="$t('calculator.allowed-ip.placeholder')" :class="{ 'is-invalid': errorAllowed }">
|
||||
<div v-if="errorAllowed" class="text-danger mt-1">{{ errorAllowed }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('calculator.dissallowed-ip.label') }}</label>
|
||||
<input class="form-control" v-model="dissallowedIp" :placeholder="$t('calculator.dissallowed-ip.placeholder')" :class="{ 'is-invalid': errorDissallowed }">
|
||||
<div v-if="errorDissallowed" class="text-danger mt-1">{{ errorDissallowed }}</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<hr class="mt-4">
|
||||
<button class="btn btn-primary mb-4" type="button" @click="addPrivateIPs">{{ $t('calculator.button-exclude-private') }}</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="col-12 col-lg-2 mt-sm-4">
|
||||
</div>
|
||||
<div class="col-12 col-lg-5">
|
||||
<h1>{{ $t('calculator.headline-allowed-ip') }}</h1>
|
||||
<fieldset>
|
||||
<div class="form-group">
|
||||
<textarea class="form-control" :value="newAllowedIp" rows="6" :placeholder="$t('calculator.new-allowed-ip.placeholder')" readonly></textarea>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -5,17 +5,20 @@ import PeerMultiCreateModal from "../components/PeerMultiCreateModal.vue";
|
||||
import InterfaceEditModal from "../components/InterfaceEditModal.vue";
|
||||
import InterfaceViewModal from "../components/InterfaceViewModal.vue";
|
||||
|
||||
import {onMounted, ref} from "vue";
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import {peerStore} from "@/stores/peers";
|
||||
import {interfaceStore} from "@/stores/interfaces";
|
||||
import {notify} from "@kyvg/vue3-notification";
|
||||
import {settingsStore} from "@/stores/settings";
|
||||
import {humanFileSize} from '@/helpers/utils';
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
const settings = settingsStore()
|
||||
const interfaces = interfaceStore()
|
||||
const peers = peerStore()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const viewedPeerId = ref("")
|
||||
const editPeerId = ref("")
|
||||
const multiCreatePeerId = ref("")
|
||||
@@ -26,6 +29,10 @@ const sortKey = ref("")
|
||||
const sortOrder = ref(1)
|
||||
const selectAll = ref(false)
|
||||
|
||||
const selectedPeers = computed(() => {
|
||||
return peers.All.filter(peer => peer.IsSelected).map(peer => peer.Identifier);
|
||||
})
|
||||
|
||||
function sortBy(key) {
|
||||
if (sortKey.value === key) {
|
||||
sortOrder.value = sortOrder.value * -1; // Toggle sort order
|
||||
@@ -45,6 +52,33 @@ function calculateInterfaceName(id, name) {
|
||||
return result
|
||||
}
|
||||
|
||||
const calculateBackendName = computed(() => {
|
||||
let backendId = interfaces.GetSelected.Backend
|
||||
|
||||
let backendName = t('interfaces.interface.unknown-backend')
|
||||
let availableBackends = settings.Setting('AvailableBackends') || []
|
||||
availableBackends.forEach(backend => {
|
||||
if (backend.Id === backendId) {
|
||||
backendName = backend.Id === 'local' ? t(backend.Name) : backend.Name
|
||||
}
|
||||
})
|
||||
return backendName
|
||||
})
|
||||
|
||||
const isBackendValid = computed(() => {
|
||||
let backendId = interfaces.GetSelected.Backend
|
||||
|
||||
let valid = false
|
||||
let availableBackends = settings.Setting('AvailableBackends') || []
|
||||
availableBackends.forEach(backend => {
|
||||
if (backend.Id === backendId) {
|
||||
valid = true
|
||||
}
|
||||
})
|
||||
return valid
|
||||
})
|
||||
|
||||
|
||||
async function download() {
|
||||
await interfaces.LoadInterfaceConfig(interfaces.GetSelected.Identifier)
|
||||
|
||||
@@ -81,6 +115,39 @@ async function saveConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDelete() {
|
||||
if (confirm(t('interfaces.confirm-bulk-delete', {count: selectedPeers.value.length}))) {
|
||||
try {
|
||||
await peers.BulkDelete(selectedPeers.value)
|
||||
selectAll.value = false // reset selection
|
||||
} catch (e) {
|
||||
// notification is handled in store
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkEnable() {
|
||||
try {
|
||||
await peers.BulkEnable(selectedPeers.value)
|
||||
selectAll.value = false
|
||||
peers.All.forEach(p => p.IsSelected = false) // remove selection
|
||||
} catch (e) {
|
||||
// notification is handled in store
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDisable() {
|
||||
if (confirm(t('interfaces.confirm-bulk-disable', {count: selectedPeers.value.length}))) {
|
||||
try {
|
||||
await peers.BulkDisable(selectedPeers.value)
|
||||
selectAll.value = false
|
||||
peers.All.forEach(p => p.IsSelected = false) // remove selection
|
||||
} catch (e) {
|
||||
// notification is handled in store
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
peers.FilteredAndPaged.forEach(peer => {
|
||||
peer.IsSelected = selectAll.value;
|
||||
@@ -112,7 +179,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-group mb-3">
|
||||
<button class="input-group-text btn btn-primary" :title="$t('interfaces.button-add-interface')" @click.prevent="editInterfaceId='#NEW#'">
|
||||
<button class="btn btn-primary" :title="$t('interfaces.button-add-interface')" @click.prevent="editInterfaceId='#NEW#'">
|
||||
<i class="fa-solid fa-plus-circle"></i>
|
||||
</button>
|
||||
<select v-model="interfaces.selected" :disabled="interfaces.Count===0" class="form-select" @change="() => { peers.LoadPeers(); peers.LoadStats() }">
|
||||
@@ -141,8 +208,14 @@ onMounted(async () => {
|
||||
<div class="card-header">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8">
|
||||
{{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{interfaces.GetSelected.Mode}} {{ $t('interfaces.interface.mode') }})
|
||||
{{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{ $t('modals.interface-edit.mode.' + interfaces.GetSelected.Mode )}} | {{ $t('interfaces.interface.backend') + ": " + calculateBackendName }}<span v-if="!isBackendValid" :title="t('interfaces.interface.wrong-backend')" class="ms-1 me-1"><i class="fa-solid fa-triangle-exclamation"></i></span>)
|
||||
<span v-if="interfaces.GetSelected.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="interfaces.GetSelected.DisabledReason"></i></span>
|
||||
<div v-if="interfaces.GetSelected && (interfaces.TrafficStats.Received > 0 || interfaces.TrafficStats.Transmitted > 0)" class="mt-2">
|
||||
<small class="text-muted">
|
||||
Traffic: <i class="fa-solid fa-arrow-down me-1"></i>{{ humanFileSize(interfaces.TrafficStats.Received) }}/s
|
||||
<i class="fa-solid fa-arrow-up ms-1 me-1"></i>{{ humanFileSize(interfaces.TrafficStats.Transmitted) }}/s
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4 text-lg-end">
|
||||
<a class="btn-link" href="#" :title="$t('interfaces.interface.button-show-config')" @click.prevent="viewedInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-eye"></i></a>
|
||||
@@ -187,14 +260,14 @@ onMounted(async () => {
|
||||
<td>{{ $t('interfaces.interface.ip') }}:</td>
|
||||
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Addresses" :key="addr">{{addr}}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('interfaces.interface.dns') }}:</td>
|
||||
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.Dns" :key="addr">{{addr}}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('interfaces.interface.mtu') }}:</td>
|
||||
<td>{{interfaces.GetSelected.Mtu}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('interfaces.interface.default-dns') }}:</td>
|
||||
<td><span class="badge bg-light me-1" v-for="addr in interfaces.GetSelected.PeerDefDns" :key="addr">{{addr}}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('interfaces.interface.default-keep-alive') }}:</td>
|
||||
<td>{{interfaces.GetSelected.PeerDefPersistentKeepalive}}</td>
|
||||
@@ -314,7 +387,7 @@ onMounted(async () => {
|
||||
<div class="form-group d-inline">
|
||||
<div class="input-group mb-3">
|
||||
<input v-model="peers.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="peers.afterPageSizeChange">
|
||||
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
|
||||
<button class="btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,6 +396,13 @@ onMounted(async () => {
|
||||
<a class="btn btn-primary ms-2" href="#" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a>
|
||||
</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="peers.Count===0">
|
||||
<h4>{{ $t('interfaces.no-peer.headline') }}</h4>
|
||||
@@ -370,21 +450,26 @@ 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>
|
||||
</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>{{peer.UserIdentifier}}</td>
|
||||
<td><span :title="peer.UserDisplayName">{{peer.UserIdentifier}}</span></td>
|
||||
<td>
|
||||
<span v-for="ip in peer.Addresses" :key="ip" class="badge bg-light me-1">{{ ip }}</span>
|
||||
</td>
|
||||
<td v-if="interfaces.GetSelected.Mode==='client'">{{peer.Endpoint.Value}}</td>
|
||||
<td v-if="peers.hasStatistics">
|
||||
<div v-if="peers.Statistics(peer.Identifier).IsConnected">
|
||||
<span class="badge rounded-pill bg-success" :title="$t('interfaces.peer-connected')"><i class="fa-solid fa-link"></i></span> <span :title="$t('interfaces.peer-handshake') + ' ' + peers.Statistics(peer.Identifier).LastHandshake">{{ $t('interfaces.peer-connected') }}</span>
|
||||
<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>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span class="badge rounded-pill bg-light" :title="$t('interfaces.peer-not-connected')"><i class="fa-solid fa-link-slash"></i></span>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="peers.hasStatistics" >
|
||||
<span class="text-center" >{{ humanFileSize(peers.Statistics(peer.Identifier).BytesReceived) }} / {{ humanFileSize(peers.Statistics(peer.Identifier).BytesTransmitted) }}</span>
|
||||
<div class="d-flex flex-column">
|
||||
<span :title="humanFileSize(peers.Statistics(peer.Identifier).BytesReceived) + ' / ' + humanFileSize(peers.Statistics(peer.Identifier).BytesTransmitted)">
|
||||
<i class="fa-solid fa-arrow-down me-1"></i>{{ humanFileSize(peers.TrafficStats(peer.Identifier).Received) }}/s
|
||||
<i class="fa-solid fa-arrow-up ms-1 me-1"></i>{{ humanFileSize(peers.TrafficStats(peer.Identifier).Transmitted) }}/s
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<a href="#" :title="$t('interfaces.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a>
|
||||
@@ -429,3 +514,5 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style>
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
<script setup>
|
||||
import PeerViewModal from "../components/PeerViewModal.vue";
|
||||
|
||||
import { onMounted, ref } from "vue";
|
||||
import { onMounted, ref, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { profileStore } from "@/stores/profile";
|
||||
import { peerStore } from "@/stores/peers";
|
||||
import UserPeerEditModal from "@/components/UserPeerEditModal.vue";
|
||||
import { settingsStore } from "@/stores/settings";
|
||||
import { humanFileSize } from "@/helpers/utils";
|
||||
|
||||
const settings = settingsStore()
|
||||
const profile = profileStore()
|
||||
const peers = peerStore()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const viewedPeerId = ref("")
|
||||
const editPeerId = ref("")
|
||||
@@ -17,6 +22,10 @@ const sortKey = ref("")
|
||||
const sortOrder = ref(1)
|
||||
const selectAll = ref(false)
|
||||
|
||||
const selectedPeers = computed(() => {
|
||||
return profile.Peers.filter(peer => peer.IsSelected).map(peer => peer.Identifier);
|
||||
})
|
||||
|
||||
function sortBy(key) {
|
||||
if (sortKey.value === key) {
|
||||
sortOrder.value = sortOrder.value * -1; // Toggle sort order
|
||||
@@ -35,6 +44,17 @@ function friendlyInterfaceName(id, name) {
|
||||
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() {
|
||||
profile.FilteredAndPagedPeers.forEach(peer => {
|
||||
peer.IsSelected = selectAll.value;
|
||||
@@ -65,7 +85,7 @@ onMounted(async () => {
|
||||
<div class="input-group mb-3">
|
||||
<input v-model="profile.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text"
|
||||
@keyup="profile.afterPageSizeChange">
|
||||
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i
|
||||
<button class="btn btn-primary" :title="$t('general.search.button')"><i
|
||||
class="fa-solid fa-search"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,7 +93,7 @@ onMounted(async () => {
|
||||
<div class="col-12 col-lg-3 text-lg-end">
|
||||
<div class="form-group" v-if="settings.Setting('SelfProvisioning')">
|
||||
<div class="input-group mb-3">
|
||||
<button class="input-group-text 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>
|
||||
</button>
|
||||
<select v-model="profile.selectedInterfaceId" :disabled="profile.CountInterfaces===0" class="form-select">
|
||||
@@ -84,6 +104,13 @@ onMounted(async () => {
|
||||
</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 v-if="profile.CountPeers === 0">
|
||||
<h4>{{ $t('profile.no-peer.headline') }}</h4>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup>
|
||||
import {onMounted, ref} from "vue";
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import { profileStore } from "@/stores/profile";
|
||||
import { settingsStore } from "@/stores/settings";
|
||||
import { authStore } from "../stores/auth";
|
||||
import {notify} from "@kyvg/vue3-notification";
|
||||
|
||||
const profile = profileStore()
|
||||
const settings = settingsStore()
|
||||
const auth = authStore()
|
||||
|
||||
const webBasePath = ref(WGPORTAL_BASE_PATH);
|
||||
|
||||
onMounted(async () => {
|
||||
await profile.LoadUser()
|
||||
await auth.LoadWebAuthnCredentials()
|
||||
@@ -34,6 +37,45 @@ async function saveRename(credential) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -43,56 +85,49 @@ async function saveRename(credential) {
|
||||
|
||||
<p class="lead">{{ $t('settings.abstract') }}</p>
|
||||
|
||||
<div v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')">
|
||||
<div class="bg-light 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 class="card border-secondary p-5 mt-5" v-if="profile.user.Source === 'db'">
|
||||
<h2 class="display-7">{{ $t('settings.password.headline') }}</h2>
|
||||
<p class="lead">{{ $t('settings.password.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4" for="oldpw">{{ $t('settings.password.current-label') }}</label>
|
||||
<input id="oldpw" v-model="pwFormData.OldPassword" class="form-control" :class="{ 'is-invalid': pwFormData.Password && !pwFormData.OldPassword }" type="password">
|
||||
</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 class="col-6">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="form-group has-success">
|
||||
<label class="form-label mt-4" for="newpw">{{ $t('settings.password.new-label') }}</label>
|
||||
<input id="newpw" v-model="pwFormData.Password" class="form-control" :class="{ 'is-invalid': passwordWeak, 'is-valid': pwFormData.Password !== '' && !passwordWeak }" type="password">
|
||||
<div class="invalid-feedback" v-if="passwordWeak">{{ $t('settings.password.weak-label') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-5">
|
||||
<div class="col-6">
|
||||
<button class="input-group-text 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="/api/v1/doc.html" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
|
||||
<div class="col-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4" for="confirmnewpw">{{ $t('settings.password.new-confirm-label') }}</label>
|
||||
<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">
|
||||
<div class="invalid-feedback" v-if="pwFormData.PasswordRepeat !== ''&& pwFormData.Password !== pwFormData.PasswordRepeat">{{ $t('settings.password.invalid-confirm-label') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-light 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="input-group-text 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 class="row mt-5">
|
||||
<div class="col-6">
|
||||
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="updatePassword" :disabled="profile.isFetching || !passwordChangeAllowed">
|
||||
<i class="fa-solid fa-floppy-disk"></i> {{ $t('settings.password.change-button-text') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-light p-5 mt-5" v-if="settings.Setting('WebAuthnEnabled')">
|
||||
<div class="card border-secondary p-5 mt-5" v-if="settings.Setting('WebAuthnEnabled')">
|
||||
<h2 class="display-7">{{ $t('settings.webauthn.headline') }}</h2>
|
||||
<p class="lead">{{ $t('settings.webauthn.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
@@ -101,7 +136,7 @@ async function saveRename(credential) {
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<button class="input-group-text btn btn-primary" :title="$t('settings.webauthn.button-register-text')" @click.prevent="auth.RegisterWebAuthn" :disabled="auth.isFetching">
|
||||
<button class="btn btn-primary" :title="$t('settings.webauthn.button-register-text')" @click.prevent="auth.RegisterWebAuthn" :disabled="auth.isFetching">
|
||||
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.webauthn.button-register-title') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -173,4 +208,53 @@ async function saveRename(credential) {
|
||||
</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>
|
||||
|
||||
@@ -1,16 +1,77 @@
|
||||
<script setup>
|
||||
import {userStore} from "@/stores/users";
|
||||
import {ref,onMounted} from "vue";
|
||||
import {ref, onMounted, computed} from "vue";
|
||||
import UserEditModal from "../components/UserEditModal.vue";
|
||||
import UserViewModal from "../components/UserViewModal.vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
const users = userStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const editUserId = ref("")
|
||||
const viewedUserId = ref("")
|
||||
|
||||
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() {
|
||||
users.FilteredAndPaged.forEach(user => {
|
||||
user.IsSelected = selectAll.value;
|
||||
@@ -35,7 +96,7 @@ onMounted(() => {
|
||||
<div class="form-group d-inline">
|
||||
<div class="input-group mb-3">
|
||||
<input v-model="users.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="users.afterPageSizeChange">
|
||||
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
|
||||
<button class="btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,6 +106,15 @@ onMounted(() => {
|
||||
</a>
|
||||
</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 v-if="users.Count===0">
|
||||
<h4>{{ $t('users.no-user.headline') }}</h4>
|
||||
@@ -61,7 +131,7 @@ onMounted(() => {
|
||||
<th scope="col">{{ $t('users.table-heading.email') }}</th>
|
||||
<th scope="col">{{ $t('users.table-heading.firstname') }}</th>
|
||||
<th scope="col">{{ $t('users.table-heading.lastname') }}</th>
|
||||
<th class="text-center" scope="col">{{ $t('users.table-heading.source') }}</th>
|
||||
<th class="text-center" scope="col">{{ $t('users.table-heading.sources') }}</th>
|
||||
<th class="text-center" scope="col">{{ $t('users.table-heading.peers') }}</th>
|
||||
<th class="text-center" scope="col">{{ $t('users.table-heading.admin') }}</th>
|
||||
<th scope="col"></th><!-- Actions -->
|
||||
@@ -80,7 +150,7 @@ onMounted(() => {
|
||||
<td>{{user.Email}}</td>
|
||||
<td>{{user.Firstname}}</td>
|
||||
<td>{{user.Lastname}}</td>
|
||||
<td class="text-center"><span class="badge rounded-pill bg-light">{{user.Source}}</span></td>
|
||||
<td><span class="badge bg-light me-1" v-for="src in user.AuthSources" :key="src">{{src}}</span></td>
|
||||
<td class="text-center">{{user.PeerCount}}</td>
|
||||
<td class="text-center">
|
||||
<span v-if="user.IsAdmin" class="text-danger" :title="$t('users.admin')"><i class="fa fa-check-circle"></i></span>
|
||||
|
||||
102
go.mod
102
go.mod
@@ -4,98 +4,106 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/a8m/envsubst v1.4.3
|
||||
github.com/alexedwards/scs/v2 v2.8.0
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/alexedwards/scs/v2 v2.9.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.11
|
||||
github.com/go-pkgz/routegroup v1.4.1
|
||||
github.com/go-playground/validator/v10 v10.26.0
|
||||
github.com/go-webauthn/webauthn v0.13.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/go-pkgz/routegroup v1.6.0
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/go-webauthn/webauthn v0.15.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/prometheus-community/pro-bing v0.7.0
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/swaggo/swag v1.16.4
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/swag v1.16.6
|
||||
github.com/vardius/message-bus v1.1.5
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5
|
||||
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlserver v1.6.0
|
||||
gorm.io/gorm v1.30.0
|
||||
gorm.io/driver/sqlserver v1.6.3
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.4 // indirect
|
||||
github.com/go-openapi/spec v0.22.2 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/go-test/deep v1.1.1 // indirect
|
||||
github.com/go-webauthn/x v0.1.21 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-webauthn/x v0.1.26 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
github.com/google/go-tpm v0.9.7 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.4 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/josharian/native v1.1.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||
github.com/mdlayher/netlink v1.8.0 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.8.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.5 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.63.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.0 // indirect
|
||||
github.com/prometheus/common v0.67.4 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
modernc.org/libc v1.63.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
modernc.org/libc v1.67.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.10.0 // indirect
|
||||
modernc.org/sqlite v1.37.0 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.40.1 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
350
go.sum
350
go.sum
@@ -1,38 +1,46 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0/go.mod h1:bhXu1AjYL+wutSL/kpSq6s7733q2Rb0yuot9Zgfqa/0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
|
||||
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.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo=
|
||||
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.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.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/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw=
|
||||
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.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/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80=
|
||||
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.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/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
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/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
|
||||
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
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/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -40,91 +48,111 @@ 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/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/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
|
||||
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
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/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
|
||||
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
||||
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
|
||||
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
|
||||
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
|
||||
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
|
||||
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
|
||||
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
|
||||
github.com/go-pkgz/routegroup v1.4.1 h1:iw1yW3lXuurZZOv/DF9fY8Mkpvy6J9UjBiP1oDIQE/s=
|
||||
github.com/go-pkgz/routegroup v1.4.1/go.mod h1:kDDPDRLRiRY1vnENrZJw1jQAzQX7fvsbsHGRQFNQfKc=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
|
||||
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
|
||||
github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc=
|
||||
github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4=
|
||||
github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/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/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-webauthn/webauthn v0.13.0 h1:cJIL1/1l+22UekVhipziAaSgESJxokYkowUqAIsWs0Y=
|
||||
github.com/go-webauthn/webauthn v0.13.0/go.mod h1:Oy9o2o79dbLKRPZWWgRIOdtBGAhKnDIaBp2PFkICRHs=
|
||||
github.com/go-webauthn/x v0.1.21 h1:nFbckQxudvHEJn2uy1VEi713MeSpApoAv9eRqsb9AdQ=
|
||||
github.com/go-webauthn/x v0.1.21/go.mod h1:sEYohtg1zL4An1TXIUIQ5csdmoO+WO0R4R2pGKaHYKA=
|
||||
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
|
||||
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
|
||||
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
|
||||
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/google/go-cmp v0.5.9/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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
|
||||
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
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/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/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
@@ -133,72 +161,77 @@ 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/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
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/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
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/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
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/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
|
||||
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
|
||||
github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM=
|
||||
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 v0.19.0/go.mod h1:ukJCBnnzLzpVF0qYRT+eg1e+eSwjeQ7IvenUv8QPook=
|
||||
github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw=
|
||||
github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo=
|
||||
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
|
||||
github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
|
||||
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
|
||||
github.com/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/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/montanaflynn/stats v0.6.6/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/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/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/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
|
||||
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
|
||||
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
|
||||
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
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/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.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.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
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/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
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/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||
@@ -219,43 +252,59 @@ github.com/yeqown/go-qrcode/writer/compressed v1.0.1/go.mod h1:BJScsGUIKM+eg0CCL
|
||||
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
||||
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
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.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
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.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/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-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
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.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -263,108 +312,121 @@ 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.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220224120231-95c6836cb0e7/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
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.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
|
||||
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/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
|
||||
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
|
||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||
gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI=
|
||||
gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
|
||||
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
|
||||
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.63.0 h1:wKzb61wOGCzgahQBORb1b0dZonh8Ufzl/7r4Yf1D5YA=
|
||||
modernc.org/libc v1.63.0/go.mod h1:wDzH1mgz1wUIEwottFt++POjGRO9sgyQKrpXaz3x89E=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
|
||||
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
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/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
||||
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
@@ -23,9 +23,6 @@ import (
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
// SchemaVersion describes the current database schema version. It must be incremented if a manual migration is needed.
|
||||
var SchemaVersion uint64 = 1
|
||||
|
||||
// SysStat stores the current database schema version and the timestamp when it was applied.
|
||||
type SysStat struct {
|
||||
MigratedAt time.Time `gorm:"column:migrated_at"`
|
||||
@@ -166,6 +163,9 @@ func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||
if err != nil {
|
||||
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.SetMaxOpenConns(1)
|
||||
}
|
||||
@@ -176,13 +176,15 @@ func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||
// SqlRepo is a SQL database repository implementation.
|
||||
// Currently, it supports MySQL, SQLite, Microsoft SQL and Postgresql database systems.
|
||||
type SqlRepo struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewSqlRepository creates a new SqlRepo instance.
|
||||
func NewSqlRepository(db *gorm.DB) (*SqlRepo, error) {
|
||||
func NewSqlRepository(db *gorm.DB, cfg *config.Config) (*SqlRepo, error) {
|
||||
repo := &SqlRepo{
|
||||
db: db,
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
if err := repo.preCheck(); err != nil {
|
||||
@@ -220,6 +222,8 @@ func (r *SqlRepo) preCheck() error {
|
||||
func (r *SqlRepo) migrate() error {
|
||||
slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{}))
|
||||
slog.Debug("running migration: user", "result", r.db.AutoMigrate(&domain.User{}))
|
||||
slog.Debug("running migration: user authentications", "result",
|
||||
r.db.AutoMigrate(&domain.UserAuthentication{}))
|
||||
slog.Debug("running migration: user webauthn credentials", "result",
|
||||
r.db.AutoMigrate(&domain.UserWebauthnCredential{}))
|
||||
slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{}))
|
||||
@@ -229,16 +233,88 @@ func (r *SqlRepo) migrate() error {
|
||||
slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{}))
|
||||
|
||||
existingSysStat := SysStat{}
|
||||
r.db.Where("schema_version = ?", SchemaVersion).First(&existingSysStat)
|
||||
r.db.Order("schema_version desc").First(&existingSysStat) // get latest version
|
||||
|
||||
// Migration: 0 --> 1
|
||||
if existingSysStat.SchemaVersion == 0 {
|
||||
const schemaVersion = 1
|
||||
sysStat := SysStat{
|
||||
MigratedAt: time.Now(),
|
||||
SchemaVersion: SchemaVersion,
|
||||
SchemaVersion: schemaVersion,
|
||||
}
|
||||
if err := r.db.Create(&sysStat).Error; err != nil {
|
||||
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", SchemaVersion, err)
|
||||
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
|
||||
}
|
||||
slog.Debug("sys-stat entry written", "schema_version", SchemaVersion)
|
||||
slog.Debug("sys-stat entry written", "schema_version", schemaVersion)
|
||||
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
|
||||
}
|
||||
|
||||
// Migration: 1 --> 2
|
||||
if existingSysStat.SchemaVersion == 1 {
|
||||
const schemaVersion = 2
|
||||
// Preserve existing behavior for installations that had default-peer-creation enabled.
|
||||
if r.cfg.Core.CreateDefaultPeer {
|
||||
err := r.db.Model(&domain.Interface{}).
|
||||
Where("type = ?", domain.InterfaceTypeServer).
|
||||
Update("create_default_peer", true).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to migrate interface flags for schema version %d: %w", schemaVersion, err)
|
||||
}
|
||||
slog.Debug("migrated interface create_default_peer flags", "schema_version", schemaVersion)
|
||||
}
|
||||
sysStat := SysStat{
|
||||
MigratedAt: time.Now(),
|
||||
SchemaVersion: schemaVersion,
|
||||
}
|
||||
if err := r.db.Create(&sysStat).Error; err != nil {
|
||||
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
|
||||
}
|
||||
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
|
||||
}
|
||||
|
||||
// Migration: 2 --> 3
|
||||
if existingSysStat.SchemaVersion == 2 {
|
||||
const schemaVersion = 3
|
||||
// Migration to multi-auth
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var users []domain.User
|
||||
if err := tx.Find(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, user := range users {
|
||||
auth := domain.UserAuthentication{
|
||||
BaseModel: domain.BaseModel{
|
||||
CreatedBy: domain.CtxSystemDBMigrator,
|
||||
UpdatedBy: domain.CtxSystemDBMigrator,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
UserIdentifier: user.Identifier,
|
||||
Source: user.Source,
|
||||
ProviderName: user.ProviderName,
|
||||
}
|
||||
if err := tx.Create(&auth).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("migrated users to multi-auth model", "schema_version", schemaVersion)
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to migrate to multi-auth: %w", err)
|
||||
}
|
||||
sysStat := SysStat{
|
||||
MigratedAt: time.Now(),
|
||||
SchemaVersion: schemaVersion,
|
||||
}
|
||||
if err := r.db.Create(&sysStat).Error; err != nil {
|
||||
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
|
||||
}
|
||||
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -748,7 +824,7 @@ func (r *SqlRepo) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr
|
||||
func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
|
||||
var user domain.User
|
||||
|
||||
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").First(&user, id).Error
|
||||
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Preload("Authentications").First(&user, id).Error
|
||||
|
||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, domain.ErrNotFound
|
||||
@@ -766,7 +842,8 @@ func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domai
|
||||
func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
var users []domain.User
|
||||
|
||||
err := r.db.WithContext(ctx).Where("email = ?", email).Preload("WebAuthnCredentialList").Find(&users).Error
|
||||
err := r.db.WithContext(ctx).Where("email = ?",
|
||||
email).Preload("WebAuthnCredentialList").Preload("Authentications").Find(&users).Error
|
||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
@@ -806,7 +883,7 @@ func (r *SqlRepo) GetUserByWebAuthnCredential(ctx context.Context, credentialIdB
|
||||
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
||||
var users []domain.User
|
||||
|
||||
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Find(&users).Error
|
||||
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Preload("Authentications").Find(&users).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -826,6 +903,7 @@ func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User,
|
||||
Or("lastname LIKE ?", searchValue).
|
||||
Or("email LIKE ?", searchValue).
|
||||
Preload("WebAuthnCredentialList").
|
||||
Preload("Authentications").
|
||||
Find(&users).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -885,7 +963,17 @@ func (r *SqlRepo) getOrCreateUser(ui *domain.ContextUserInfo, tx *gorm.DB, id do
|
||||
) {
|
||||
var user domain.User
|
||||
|
||||
// userDefaults will be applied to newly created user records
|
||||
result := tx.Model(&user).Preload("WebAuthnCredentialList").Preload("Authentications").Find(&user, id)
|
||||
if result.Error != nil {
|
||||
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, result.Error
|
||||
}
|
||||
}
|
||||
if result.Error == nil && result.RowsAffected > 0 {
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// create a new user record if no user record exists yet
|
||||
userDefaults := domain.User{
|
||||
BaseModel: domain.BaseModel{
|
||||
CreatedBy: ui.UserId(),
|
||||
@@ -894,16 +982,15 @@ func (r *SqlRepo) getOrCreateUser(ui *domain.ContextUserInfo, tx *gorm.DB, id do
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
Identifier: id,
|
||||
Source: domain.UserSourceDatabase,
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
err := tx.Attrs(userDefaults).FirstOrCreate(&user, id).Error
|
||||
err := tx.Create(&userDefaults).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
return &userDefaults, nil
|
||||
}
|
||||
|
||||
func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *domain.User) error {
|
||||
@@ -920,6 +1007,11 @@ func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *doma
|
||||
return fmt.Errorf("failed to update users webauthn credentials: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
1044
internal/adapters/wgcontroller/local.go
Normal file
1044
internal/adapters/wgcontroller/local.go
Normal file
File diff suppressed because it is too large
Load Diff
1208
internal/adapters/wgcontroller/mikrotik.go
Normal file
1208
internal/adapters/wgcontroller/mikrotik.go
Normal file
File diff suppressed because it is too large
Load Diff
979
internal/adapters/wgcontroller/pfsense.go
Normal file
979
internal/adapters/wgcontroller/pfsense.go
Normal file
@@ -0,0 +1,979 @@
|
||||
package wgcontroller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
"github.com/h44z/wg-portal/internal/lowlevel"
|
||||
)
|
||||
|
||||
// PfsenseController implements the InterfaceController interface for pfSense firewalls.
|
||||
// It uses the pfSense REST API (https://pfrest.org/) to manage WireGuard interfaces and peers.
|
||||
// API endpoint paths and field names should be verified against the Swagger documentation:
|
||||
// https://pfrest.org/api-docs/
|
||||
|
||||
type PfsenseController struct {
|
||||
coreCfg *config.Config
|
||||
cfg *config.BackendPfsense
|
||||
|
||||
client *lowlevel.PfsenseApiClient
|
||||
|
||||
// Add mutexes to prevent race conditions
|
||||
interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*sync.Mutex
|
||||
peerMutexes sync.Map // map[domain.PeerIdentifier]*sync.Mutex
|
||||
coreMutex sync.Mutex // for updating the core configuration such as routing table or DNS settings
|
||||
}
|
||||
|
||||
func NewPfsenseController(coreCfg *config.Config, cfg *config.BackendPfsense) (*PfsenseController, error) {
|
||||
client, err := lowlevel.NewPfsenseApiClient(coreCfg, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create pfSense API client: %w", err)
|
||||
}
|
||||
|
||||
return &PfsenseController{
|
||||
coreCfg: coreCfg,
|
||||
cfg: cfg,
|
||||
|
||||
client: client,
|
||||
|
||||
interfaceMutexes: sync.Map{},
|
||||
peerMutexes: sync.Map{},
|
||||
coreMutex: sync.Mutex{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) GetId() domain.InterfaceBackend {
|
||||
return domain.InterfaceBackend(c.cfg.Id)
|
||||
}
|
||||
|
||||
// getInterfaceMutex returns a mutex for the given interface to prevent concurrent modifications
|
||||
func (c *PfsenseController) getInterfaceMutex(id domain.InterfaceIdentifier) *sync.Mutex {
|
||||
mutex, _ := c.interfaceMutexes.LoadOrStore(id, &sync.Mutex{})
|
||||
return mutex.(*sync.Mutex)
|
||||
}
|
||||
|
||||
// getPeerMutex returns a mutex for the given peer to prevent concurrent modifications
|
||||
func (c *PfsenseController) getPeerMutex(id domain.PeerIdentifier) *sync.Mutex {
|
||||
mutex, _ := c.peerMutexes.LoadOrStore(id, &sync.Mutex{})
|
||||
return mutex.(*sync.Mutex)
|
||||
}
|
||||
|
||||
// region wireguard-related
|
||||
|
||||
func (c *PfsenseController) GetInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) {
|
||||
// Query WireGuard tunnels from pfSense API
|
||||
// Using pfSense REST API v2 endpoints: GET /api/v2/vpn/wireguard/tunnels
|
||||
// Field names should be verified against Swagger docs: https://pfrest.org/api-docs/
|
||||
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{})
|
||||
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return nil, fmt.Errorf("failed to query interfaces: %v", wgReply.Error)
|
||||
}
|
||||
|
||||
// Parallelize loading of interface details to speed up overall latency.
|
||||
// Use a bounded semaphore to avoid overloading the pfSense device.
|
||||
maxConcurrent := c.cfg.GetConcurrency()
|
||||
sem := make(chan struct{}, maxConcurrent)
|
||||
|
||||
interfaces := make([]domain.PhysicalInterface, 0, len(wgReply.Data))
|
||||
var mu sync.Mutex
|
||||
var wgWait sync.WaitGroup
|
||||
var firstErr error
|
||||
ctx2, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
for _, wgObj := range wgReply.Data {
|
||||
wgWait.Add(1)
|
||||
sem <- struct{}{} // block if more than maxConcurrent requests are processing
|
||||
go func(wg lowlevel.GenericJsonObject) {
|
||||
defer wgWait.Done()
|
||||
defer func() { <-sem }() // read from the semaphore and make space for the next entry
|
||||
if firstErr != nil {
|
||||
return
|
||||
}
|
||||
pi, err := c.loadInterfaceData(ctx2, wg)
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
cancel()
|
||||
}
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
interfaces = append(interfaces, *pi)
|
||||
mu.Unlock()
|
||||
}(wgObj)
|
||||
}
|
||||
|
||||
wgWait.Wait()
|
||||
if firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
|
||||
return interfaces, nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (
|
||||
*domain.PhysicalInterface,
|
||||
error,
|
||||
) {
|
||||
// First, get the tunnel ID by querying by name
|
||||
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{
|
||||
Filters: map[string]string{
|
||||
"name": string(id),
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return nil, fmt.Errorf("failed to query interface %s: %v", id, wgReply.Error)
|
||||
}
|
||||
|
||||
if len(wgReply.Data) == 0 {
|
||||
return nil, fmt.Errorf("interface %s not found", id)
|
||||
}
|
||||
|
||||
tunnelId := wgReply.Data[0].GetString("id")
|
||||
|
||||
// Query the specific tunnel endpoint to get full details including addresses
|
||||
// Endpoint: GET /api/v2/vpn/wireguard/tunnel?id={id}
|
||||
if tunnelId != "" {
|
||||
tunnelReply := c.client.Get(ctx, "/api/v2/vpn/wireguard/tunnel", &lowlevel.PfsenseRequestOptions{
|
||||
Filters: map[string]string{
|
||||
"id": tunnelId,
|
||||
},
|
||||
})
|
||||
if tunnelReply.Status == lowlevel.PfsenseApiStatusOk && tunnelReply.Data != nil {
|
||||
// Use the detailed tunnel response which includes addresses
|
||||
return c.loadInterfaceData(ctx, tunnelReply.Data)
|
||||
}
|
||||
// Fall back to list response if detail query fails
|
||||
if c.cfg.Debug {
|
||||
slog.Debug("failed to query detailed tunnel info, using list response", "interface", id, "tunnel_id", tunnelId)
|
||||
}
|
||||
}
|
||||
|
||||
return c.loadInterfaceData(ctx, wgReply.Data[0])
|
||||
}
|
||||
|
||||
func (c *PfsenseController) loadInterfaceData(
|
||||
ctx context.Context,
|
||||
wireGuardObj lowlevel.GenericJsonObject,
|
||||
) (*domain.PhysicalInterface, error) {
|
||||
deviceName := wireGuardObj.GetString("name")
|
||||
deviceId := wireGuardObj.GetString("id")
|
||||
|
||||
// Extract addresses from the tunnel data
|
||||
// The tunnel response may include an "addresses" array when queried via /tunnel?id={id}
|
||||
addresses := c.extractAddresses(wireGuardObj, nil)
|
||||
|
||||
// If addresses weren't found in the tunnel object and we have a tunnel ID,
|
||||
// query the specific tunnel endpoint to get full details including addresses
|
||||
// Endpoint: GET /api/v2/vpn/wireguard/tunnel?id={id}
|
||||
if len(addresses) == 0 && deviceId != "" {
|
||||
tunnelReply := c.client.Get(ctx, "/api/v2/vpn/wireguard/tunnel", &lowlevel.PfsenseRequestOptions{
|
||||
Filters: map[string]string{
|
||||
"id": deviceId,
|
||||
},
|
||||
})
|
||||
if tunnelReply.Status == lowlevel.PfsenseApiStatusOk && tunnelReply.Data != nil {
|
||||
// Extract addresses from the detailed tunnel response
|
||||
parsedAddrs := c.extractAddresses(tunnelReply.Data, nil)
|
||||
if len(parsedAddrs) > 0 {
|
||||
addresses = parsedAddrs
|
||||
if c.cfg.Debug {
|
||||
slog.Debug("loaded addresses from detailed tunnel query", "interface", deviceName, "count", len(addresses))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interfaceModel, err := c.convertWireGuardInterface(wireGuardObj, nil, addresses)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("interface convert failed for %s: %w", deviceName, err)
|
||||
}
|
||||
return &interfaceModel, nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) extractAddresses(
|
||||
wgObj lowlevel.GenericJsonObject,
|
||||
ifaceObj lowlevel.GenericJsonObject,
|
||||
) []domain.Cidr {
|
||||
addresses := make([]domain.Cidr, 0)
|
||||
|
||||
// Try to get addresses from ifaceObj first
|
||||
if ifaceObj != nil {
|
||||
addrStr := ifaceObj.GetString("addresses")
|
||||
if addrStr != "" {
|
||||
// Addresses might be comma-separated or in an array
|
||||
addrs, _ := domain.CidrsFromString(addrStr)
|
||||
addresses = append(addresses, addrs...)
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get addresses from wgObj - check if it's an array first
|
||||
if len(addresses) == 0 {
|
||||
if addressesValue, ok := wgObj["addresses"]; ok && addressesValue != nil {
|
||||
if addressesArray, ok := addressesValue.([]any); ok {
|
||||
// Parse addresses array (from /tunnel?id={id} response)
|
||||
// Each object has "address" and "mask" fields
|
||||
for _, addrItem := range addressesArray {
|
||||
if addrObj, ok := addrItem.(map[string]any); ok {
|
||||
address := ""
|
||||
mask := 0
|
||||
|
||||
// Extract address
|
||||
if addrVal, ok := addrObj["address"]; ok {
|
||||
if addrStr, ok := addrVal.(string); ok {
|
||||
address = addrStr
|
||||
} else {
|
||||
address = fmt.Sprintf("%v", addrVal)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract mask
|
||||
if maskVal, ok := addrObj["mask"]; ok {
|
||||
if maskInt, ok := maskVal.(int); ok {
|
||||
mask = maskInt
|
||||
} else if maskFloat, ok := maskVal.(float64); ok {
|
||||
mask = int(maskFloat)
|
||||
} else if maskStr, ok := maskVal.(string); ok {
|
||||
if maskInt, err := strconv.Atoi(maskStr); err == nil {
|
||||
mask = maskInt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to CIDR format
|
||||
if address != "" && mask > 0 {
|
||||
cidrStr := fmt.Sprintf("%s/%d", address, mask)
|
||||
if cidr, err := domain.CidrFromString(cidrStr); err == nil {
|
||||
addresses = append(addresses, cidr)
|
||||
}
|
||||
} else if address != "" {
|
||||
// Try parsing as CIDR string directly
|
||||
if cidr, err := domain.CidrFromString(address); err == nil {
|
||||
addresses = append(addresses, cidr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if addrStr, ok := addressesValue.(string); ok {
|
||||
// Fallback: try parsing as comma-separated string
|
||||
addrs, _ := domain.CidrsFromString(addrStr)
|
||||
addresses = append(addresses, addrs...)
|
||||
}
|
||||
} else {
|
||||
// Try as string field
|
||||
addrStr := wgObj.GetString("addresses")
|
||||
if addrStr != "" {
|
||||
addrs, _ := domain.CidrsFromString(addrStr)
|
||||
addresses = append(addresses, addrs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addresses
|
||||
}
|
||||
|
||||
// parseAddressArray parses an array of address objects from the pfSense API
|
||||
// Each object has "address" and "mask" fields (similar to allowedips structure)
|
||||
func (c *PfsenseController) parseAddressArray(addressArray []lowlevel.GenericJsonObject) []domain.Cidr {
|
||||
addresses := make([]domain.Cidr, 0, len(addressArray))
|
||||
|
||||
for _, addrObj := range addressArray {
|
||||
address := addrObj.GetString("address")
|
||||
mask := addrObj.GetInt("mask")
|
||||
|
||||
if address != "" && mask > 0 {
|
||||
cidrStr := fmt.Sprintf("%s/%d", address, mask)
|
||||
if cidr, err := domain.CidrFromString(cidrStr); err == nil {
|
||||
addresses = append(addresses, cidr)
|
||||
}
|
||||
} else if address != "" {
|
||||
// Try parsing as CIDR string directly
|
||||
if cidr, err := domain.CidrFromString(address); err == nil {
|
||||
addresses = append(addresses, cidr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addresses
|
||||
}
|
||||
|
||||
func (c *PfsenseController) convertWireGuardInterface(
|
||||
wg, iface lowlevel.GenericJsonObject,
|
||||
addresses []domain.Cidr,
|
||||
) (
|
||||
domain.PhysicalInterface,
|
||||
error,
|
||||
) {
|
||||
// Map pfSense field names to our domain model
|
||||
// Field names should be verified against the Swagger UI: https://pfrest.org/api-docs/
|
||||
// The implementation attempts to handle both camelCase and kebab-case variations
|
||||
privateKey := wg.GetString("privatekey")
|
||||
if privateKey == "" {
|
||||
privateKey = wg.GetString("private-key")
|
||||
}
|
||||
publicKey := wg.GetString("publickey")
|
||||
if publicKey == "" {
|
||||
publicKey = wg.GetString("public-key")
|
||||
}
|
||||
|
||||
listenPort := wg.GetInt("listenport")
|
||||
if listenPort == 0 {
|
||||
listenPort = wg.GetInt("listen-port")
|
||||
}
|
||||
|
||||
mtu := wg.GetInt("mtu")
|
||||
running := wg.GetBool("running")
|
||||
disabled := wg.GetBool("disabled")
|
||||
|
||||
// TODO: Interface statistics (rx/tx bytes) are not currently supported
|
||||
// by the pfSense REST API. This functionality is reserved for future implementation.
|
||||
var rxBytes, txBytes uint64
|
||||
|
||||
pi := domain.PhysicalInterface{
|
||||
Identifier: domain.InterfaceIdentifier(wg.GetString("name")),
|
||||
KeyPair: domain.KeyPair{
|
||||
PrivateKey: privateKey,
|
||||
PublicKey: publicKey,
|
||||
},
|
||||
ListenPort: listenPort,
|
||||
Addresses: addresses,
|
||||
Mtu: mtu,
|
||||
FirewallMark: 0,
|
||||
DeviceUp: running && !disabled,
|
||||
ImportSource: domain.ControllerTypePfsense,
|
||||
DeviceType: domain.ControllerTypePfsense,
|
||||
BytesUpload: txBytes,
|
||||
BytesDownload: rxBytes,
|
||||
}
|
||||
|
||||
// Extract description - pfSense API uses "descr" field
|
||||
description := wg.GetString("descr")
|
||||
if description == "" {
|
||||
description = wg.GetString("description")
|
||||
}
|
||||
if description == "" {
|
||||
description = wg.GetString("comment")
|
||||
}
|
||||
|
||||
pi.SetExtras(domain.PfsenseInterfaceExtras{
|
||||
Id: wg.GetString("id"),
|
||||
Comment: description,
|
||||
Disabled: disabled,
|
||||
})
|
||||
|
||||
return pi, nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) GetPeers(ctx context.Context, deviceId domain.InterfaceIdentifier) (
|
||||
[]domain.PhysicalPeer,
|
||||
error,
|
||||
) {
|
||||
// Query all peers and filter by interface client-side
|
||||
// Using pfSense REST API v2 endpoints (https://pfrest.org/)
|
||||
// The API uses query parameters like ?id=0 for specific items, but we need to filter
|
||||
// by interface (tun field), so we fetch all peers and filter client-side
|
||||
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{})
|
||||
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return nil, fmt.Errorf("failed to query peers for %s: %v", deviceId, wgReply.Error)
|
||||
}
|
||||
|
||||
if len(wgReply.Data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Filter peers client-side by checking the "tun" field in each peer
|
||||
// pfSense peer responses use "tun" field to indicate which tunnel/interface the peer belongs to
|
||||
peers := make([]domain.PhysicalPeer, 0, len(wgReply.Data))
|
||||
for _, peer := range wgReply.Data {
|
||||
// Check if this peer belongs to the requested interface
|
||||
// pfSense uses "tun" field with the interface name (e.g., "tun_wg0")
|
||||
peerTun := peer.GetString("tun")
|
||||
if peerTun == "" {
|
||||
// Try alternative field names as fallback
|
||||
peerTun = peer.GetString("interface")
|
||||
if peerTun == "" {
|
||||
peerTun = peer.GetString("tunnel")
|
||||
}
|
||||
}
|
||||
|
||||
// Only include peers that match the requested interface name
|
||||
if peerTun != string(deviceId) {
|
||||
if c.cfg.Debug {
|
||||
slog.Debug("skipping peer - interface mismatch",
|
||||
"peer", peer.GetString("name"),
|
||||
"peer_tun", peerTun,
|
||||
"requested_interface", deviceId,
|
||||
"peer_id", peer.GetString("id"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Use peer data directly from the list response
|
||||
peerModel, err := c.convertWireGuardPeer(peer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peer convert failed for %v: %w", peer.GetString("name"), err)
|
||||
}
|
||||
peers = append(peers, peerModel)
|
||||
}
|
||||
|
||||
if c.cfg.Debug {
|
||||
slog.Debug("filtered peers for interface",
|
||||
"interface", deviceId,
|
||||
"total_peers_from_api", len(wgReply.Data),
|
||||
"filtered_peers", len(peers))
|
||||
}
|
||||
|
||||
return peers, nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) convertWireGuardPeer(peer lowlevel.GenericJsonObject) (
|
||||
domain.PhysicalPeer,
|
||||
error,
|
||||
) {
|
||||
publicKey := peer.GetString("publickey")
|
||||
if publicKey == "" {
|
||||
publicKey = peer.GetString("public-key")
|
||||
}
|
||||
|
||||
privateKey := peer.GetString("privatekey")
|
||||
if privateKey == "" {
|
||||
privateKey = peer.GetString("private-key")
|
||||
}
|
||||
|
||||
presharedKey := peer.GetString("presharedkey")
|
||||
if presharedKey == "" {
|
||||
presharedKey = peer.GetString("preshared-key")
|
||||
}
|
||||
|
||||
// pfSense returns allowedips as an array of objects with "address" and "mask" fields
|
||||
// Example: [{"address": "10.1.2.3", "mask": 32, ...}, ...]
|
||||
var allowedAddresses []domain.Cidr
|
||||
if allowedIPsValue, ok := peer["allowedips"]; ok {
|
||||
if allowedIPsArray, ok := allowedIPsValue.([]any); ok {
|
||||
// Parse array of objects
|
||||
for _, item := range allowedIPsArray {
|
||||
if itemObj, ok := item.(map[string]any); ok {
|
||||
address := ""
|
||||
mask := 0
|
||||
|
||||
// Extract address
|
||||
if addrVal, ok := itemObj["address"]; ok {
|
||||
if addrStr, ok := addrVal.(string); ok {
|
||||
address = addrStr
|
||||
} else {
|
||||
address = fmt.Sprintf("%v", addrVal)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract mask
|
||||
if maskVal, ok := itemObj["mask"]; ok {
|
||||
if maskInt, ok := maskVal.(int); ok {
|
||||
mask = maskInt
|
||||
} else if maskFloat, ok := maskVal.(float64); ok {
|
||||
mask = int(maskFloat)
|
||||
} else if maskStr, ok := maskVal.(string); ok {
|
||||
if maskInt, err := strconv.Atoi(maskStr); err == nil {
|
||||
mask = maskInt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to CIDR format (e.g., "10.1.2.3/32")
|
||||
if address != "" && mask > 0 {
|
||||
cidrStr := fmt.Sprintf("%s/%d", address, mask)
|
||||
if cidr, err := domain.CidrFromString(cidrStr); err == nil {
|
||||
allowedAddresses = append(allowedAddresses, cidr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if allowedIPsStr, ok := allowedIPsValue.(string); ok {
|
||||
// Fallback: try parsing as comma-separated string
|
||||
allowedAddresses, _ = domain.CidrsFromString(allowedIPsStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to string parsing if array parsing didn't work
|
||||
if len(allowedAddresses) == 0 {
|
||||
allowedIPsStr := peer.GetString("allowedips")
|
||||
if allowedIPsStr == "" {
|
||||
allowedIPsStr = peer.GetString("allowed-ips")
|
||||
}
|
||||
if allowedIPsStr != "" {
|
||||
allowedAddresses, _ = domain.CidrsFromString(allowedIPsStr)
|
||||
}
|
||||
}
|
||||
|
||||
endpoint := peer.GetString("endpoint")
|
||||
port := peer.GetString("port")
|
||||
|
||||
// Combine endpoint and port if both are available
|
||||
if endpoint != "" && port != "" {
|
||||
// Check if endpoint already contains a port
|
||||
if !strings.Contains(endpoint, ":") {
|
||||
endpoint = fmt.Sprintf("%s:%s", endpoint, port)
|
||||
}
|
||||
} else if endpoint == "" && port != "" {
|
||||
// If only port is available, we can't construct a full endpoint
|
||||
// This might be used with the interface's listenport
|
||||
}
|
||||
|
||||
keepAliveSeconds := 0
|
||||
keepAliveStr := peer.GetString("persistentkeepalive")
|
||||
if keepAliveStr == "" {
|
||||
keepAliveStr = peer.GetString("persistent-keepalive")
|
||||
}
|
||||
if keepAliveStr != "" {
|
||||
duration, err := time.ParseDuration(keepAliveStr)
|
||||
if err == nil {
|
||||
keepAliveSeconds = int(duration.Seconds())
|
||||
} else {
|
||||
// Try parsing as integer (seconds)
|
||||
if secs, err := strconv.Atoi(keepAliveStr); err == nil {
|
||||
keepAliveSeconds = secs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Peer statistics (last handshake, rx/tx bytes) are not currently supported
|
||||
// by the pfSense REST API. This functionality is reserved for future implementation
|
||||
// when the API adds support for these fields.
|
||||
// See: https://github.com/jaredhendrickson13/pfsense-api/issues (issue opened by user)
|
||||
//
|
||||
// When supported, extract fields like:
|
||||
// - lastHandshake: peer.GetString("lasthandshake") or peer.GetString("last-handshake")
|
||||
// - rxBytes: peer.GetInt("rxbytes") or peer.GetInt("rx-bytes")
|
||||
// - txBytes: peer.GetInt("txbytes") or peer.GetInt("tx-bytes")
|
||||
lastHandshakeTime := time.Time{}
|
||||
rxBytes := uint64(0)
|
||||
txBytes := uint64(0)
|
||||
|
||||
peerModel := domain.PhysicalPeer{
|
||||
Identifier: domain.PeerIdentifier(publicKey),
|
||||
Endpoint: endpoint,
|
||||
AllowedIPs: allowedAddresses,
|
||||
KeyPair: domain.KeyPair{
|
||||
PublicKey: publicKey,
|
||||
PrivateKey: privateKey,
|
||||
},
|
||||
PresharedKey: domain.PreSharedKey(presharedKey),
|
||||
PersistentKeepalive: keepAliveSeconds,
|
||||
LastHandshake: lastHandshakeTime,
|
||||
ProtocolVersion: 0, // pfSense may not expose protocol version
|
||||
BytesUpload: txBytes,
|
||||
BytesDownload: rxBytes,
|
||||
ImportSource: domain.ControllerTypePfsense,
|
||||
}
|
||||
|
||||
// Extract description/name - pfSense API uses "descr" field
|
||||
description := peer.GetString("descr")
|
||||
if description == "" {
|
||||
description = peer.GetString("description")
|
||||
}
|
||||
if description == "" {
|
||||
description = peer.GetString("comment")
|
||||
}
|
||||
|
||||
// Extract name - pfSense API may use "name" or "descr"
|
||||
name := peer.GetString("name")
|
||||
if name == "" {
|
||||
name = peer.GetString("descr")
|
||||
}
|
||||
if name == "" {
|
||||
name = description // fallback to description if name is not available
|
||||
}
|
||||
|
||||
peerModel.SetExtras(domain.PfsensePeerExtras{
|
||||
Id: peer.GetString("id"),
|
||||
Name: name,
|
||||
Comment: description,
|
||||
Disabled: peer.GetBool("disabled"),
|
||||
ClientEndpoint: "", // pfSense may handle this differently
|
||||
ClientAddress: "", // pfSense may handle this differently
|
||||
ClientDns: "", // pfSense may handle this differently
|
||||
ClientKeepalive: 0, // pfSense may handle this differently
|
||||
})
|
||||
|
||||
return peerModel, nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) SaveInterface(
|
||||
ctx context.Context,
|
||||
id domain.InterfaceIdentifier,
|
||||
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
|
||||
) error {
|
||||
// Lock the interface to prevent concurrent modifications
|
||||
mutex := c.getInterfaceMutex(id)
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
physicalInterface, err := c.getOrCreateInterface(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deviceId := ""
|
||||
if physicalInterface.GetExtras() != nil {
|
||||
if extras, ok := physicalInterface.GetExtras().(domain.PfsenseInterfaceExtras); ok {
|
||||
deviceId = extras.Id
|
||||
}
|
||||
}
|
||||
|
||||
if updateFunc != nil {
|
||||
physicalInterface, err = updateFunc(physicalInterface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deviceId != "" {
|
||||
// Ensure the ID is preserved
|
||||
if extras, ok := physicalInterface.GetExtras().(domain.PfsenseInterfaceExtras); ok {
|
||||
extras.Id = deviceId
|
||||
physicalInterface.SetExtras(extras)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.updateInterface(ctx, physicalInterface); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) getOrCreateInterface(
|
||||
ctx context.Context,
|
||||
id domain.InterfaceIdentifier,
|
||||
) (*domain.PhysicalInterface, error) {
|
||||
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{
|
||||
Filters: map[string]string{
|
||||
"name": string(id),
|
||||
},
|
||||
})
|
||||
if wgReply.Status == lowlevel.PfsenseApiStatusOk && len(wgReply.Data) > 0 {
|
||||
return c.loadInterfaceData(ctx, wgReply.Data[0])
|
||||
}
|
||||
|
||||
// create a new tunnel if it does not exist
|
||||
// Actual endpoint: POST /api/v2/vpn/wireguard/tunnel (singular)
|
||||
createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/tunnel", lowlevel.GenericJsonObject{
|
||||
"name": string(id),
|
||||
})
|
||||
if createReply.Status == lowlevel.PfsenseApiStatusOk {
|
||||
return c.loadInterfaceData(ctx, createReply.Data)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to create interface %s: %v", id, createReply.Error)
|
||||
}
|
||||
|
||||
func (c *PfsenseController) updateInterface(ctx context.Context, pi *domain.PhysicalInterface) error {
|
||||
extras := pi.GetExtras().(domain.PfsenseInterfaceExtras)
|
||||
interfaceId := extras.Id
|
||||
|
||||
payload := lowlevel.GenericJsonObject{
|
||||
"name": string(pi.Identifier),
|
||||
"description": extras.Comment,
|
||||
"mtu": strconv.Itoa(pi.Mtu),
|
||||
"listenport": strconv.Itoa(pi.ListenPort),
|
||||
"privatekey": pi.KeyPair.PrivateKey,
|
||||
"disabled": strconv.FormatBool(!pi.DeviceUp),
|
||||
}
|
||||
|
||||
// Add addresses if present
|
||||
if len(pi.Addresses) > 0 {
|
||||
addresses := make([]string, 0, len(pi.Addresses))
|
||||
for _, addr := range pi.Addresses {
|
||||
addresses = append(addresses, addr.String())
|
||||
}
|
||||
payload["addresses"] = strings.Join(addresses, ",")
|
||||
}
|
||||
|
||||
// Actual endpoint: PATCH /api/v2/vpn/wireguard/tunnel?id={id}
|
||||
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/tunnel?id="+interfaceId, payload)
|
||||
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return fmt.Errorf("failed to update interface %s: %v", pi.Identifier, wgReply.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error {
|
||||
// Lock the interface to prevent concurrent modifications
|
||||
mutex := c.getInterfaceMutex(id)
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
// Find the tunnel ID
|
||||
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/tunnels", &lowlevel.PfsenseRequestOptions{
|
||||
Filters: map[string]string{
|
||||
"name": string(id),
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return fmt.Errorf("unable to find WireGuard tunnel %s: %v", id, wgReply.Error)
|
||||
}
|
||||
if len(wgReply.Data) == 0 {
|
||||
return nil // tunnel does not exist, nothing to delete
|
||||
}
|
||||
|
||||
interfaceId := wgReply.Data[0].GetString("id")
|
||||
// Actual endpoint: DELETE /api/v2/vpn/wireguard/tunnel?id={id}
|
||||
deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/tunnel?id="+interfaceId)
|
||||
if deleteReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return fmt.Errorf("failed to delete WireGuard interface %s: %v", id, deleteReply.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) SavePeer(
|
||||
ctx context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
|
||||
) error {
|
||||
// Lock the peer to prevent concurrent modifications
|
||||
mutex := c.getPeerMutex(id)
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
physicalPeer, err := c.getOrCreatePeer(ctx, deviceId, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
peerId := ""
|
||||
if physicalPeer.GetExtras() != nil {
|
||||
if extras, ok := physicalPeer.GetExtras().(domain.PfsensePeerExtras); ok {
|
||||
peerId = extras.Id
|
||||
}
|
||||
}
|
||||
|
||||
physicalPeer, err = updateFunc(physicalPeer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if peerId != "" {
|
||||
// Ensure the ID is preserved
|
||||
if extras, ok := physicalPeer.GetExtras().(domain.PfsensePeerExtras); ok {
|
||||
extras.Id = peerId
|
||||
physicalPeer.SetExtras(extras)
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.updatePeer(ctx, deviceId, physicalPeer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) getOrCreatePeer(
|
||||
ctx context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
) (*domain.PhysicalPeer, error) {
|
||||
// Query for peer by publickey and interface (tun field)
|
||||
// The API uses query parameters like ?publickey=...&tun=...
|
||||
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{
|
||||
Filters: map[string]string{
|
||||
"publickey": string(id),
|
||||
"tun": string(deviceId), // Use "tun" field name as that's what the API uses
|
||||
},
|
||||
})
|
||||
if wgReply.Status == lowlevel.PfsenseApiStatusOk && len(wgReply.Data) > 0 {
|
||||
slog.Debug("found existing pfSense peer", "peer", id, "interface", deviceId)
|
||||
existingPeer, err := c.convertWireGuardPeer(wgReply.Data[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &existingPeer, nil
|
||||
}
|
||||
|
||||
// create a new peer if it does not exist
|
||||
// Actual endpoint: POST /api/v2/vpn/wireguard/peer (singular)
|
||||
slog.Debug("creating new pfSense peer", "peer", id, "interface", deviceId)
|
||||
createReply := c.client.Create(ctx, "/api/v2/vpn/wireguard/peer", lowlevel.GenericJsonObject{
|
||||
"name": fmt.Sprintf("wg-%s", id[0:8]),
|
||||
"interface": string(deviceId),
|
||||
"publickey": string(id),
|
||||
"allowedips": "0.0.0.0/0", // Use 0.0.0.0/0 as default, will be updated by updatePeer
|
||||
})
|
||||
if createReply.Status == lowlevel.PfsenseApiStatusOk {
|
||||
newPeer, err := c.convertWireGuardPeer(createReply.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Debug("successfully created pfSense peer", "peer", id, "interface", deviceId)
|
||||
return &newPeer, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to create peer %s for interface %s: %v", id, deviceId, createReply.Error)
|
||||
}
|
||||
|
||||
func (c *PfsenseController) updatePeer(
|
||||
ctx context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
pp *domain.PhysicalPeer,
|
||||
) error {
|
||||
extras := pp.GetExtras().(domain.PfsensePeerExtras)
|
||||
peerId := extras.Id
|
||||
|
||||
allowedIPsStr := domain.CidrsToString(pp.AllowedIPs)
|
||||
|
||||
slog.Debug("updating pfSense peer",
|
||||
"peer", pp.Identifier,
|
||||
"interface", deviceId,
|
||||
"allowed-ips", allowedIPsStr,
|
||||
"allowed-ips-count", len(pp.AllowedIPs),
|
||||
"disabled", extras.Disabled)
|
||||
|
||||
payload := lowlevel.GenericJsonObject{
|
||||
"name": extras.Name,
|
||||
"description": extras.Comment,
|
||||
"presharedkey": string(pp.PresharedKey),
|
||||
"publickey": pp.KeyPair.PublicKey,
|
||||
"privatekey": pp.KeyPair.PrivateKey,
|
||||
"persistentkeepalive": strconv.Itoa(pp.PersistentKeepalive),
|
||||
"disabled": strconv.FormatBool(extras.Disabled),
|
||||
"allowedips": allowedIPsStr,
|
||||
}
|
||||
|
||||
if pp.Endpoint != "" {
|
||||
payload["endpoint"] = pp.Endpoint
|
||||
}
|
||||
|
||||
// Actual endpoint: PATCH /api/v2/vpn/wireguard/peer?id={id}
|
||||
wgReply := c.client.Update(ctx, "/api/v2/vpn/wireguard/peer?id="+peerId, payload)
|
||||
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return fmt.Errorf("failed to update peer %s on interface %s: %v", pp.Identifier, deviceId, wgReply.Error)
|
||||
}
|
||||
|
||||
if extras.Disabled {
|
||||
slog.Debug("successfully disabled pfSense peer", "peer", pp.Identifier, "interface", deviceId)
|
||||
} else {
|
||||
slog.Debug("successfully updated pfSense peer", "peer", pp.Identifier, "interface", deviceId)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) DeletePeer(
|
||||
ctx context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
) error {
|
||||
// Lock the peer to prevent concurrent modifications
|
||||
mutex := c.getPeerMutex(id)
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
// Query for peer by publickey and interface (tun field)
|
||||
// The API uses query parameters like ?publickey=...&tun=...
|
||||
wgReply := c.client.Query(ctx, "/api/v2/vpn/wireguard/peers", &lowlevel.PfsenseRequestOptions{
|
||||
Filters: map[string]string{
|
||||
"publickey": string(id),
|
||||
"tun": string(deviceId), // Use "tun" field name as that's what the API uses
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return fmt.Errorf("unable to find WireGuard peer %s for interface %s: %v", id, deviceId, wgReply.Error)
|
||||
}
|
||||
if len(wgReply.Data) == 0 {
|
||||
return nil // peer does not exist, nothing to delete
|
||||
}
|
||||
|
||||
peerId := wgReply.Data[0].GetString("id")
|
||||
// Actual endpoint: DELETE /api/v2/vpn/wireguard/peer?id={id}
|
||||
deleteReply := c.client.Delete(ctx, "/api/v2/vpn/wireguard/peer?id="+peerId)
|
||||
if deleteReply.Status != lowlevel.PfsenseApiStatusOk {
|
||||
return fmt.Errorf("failed to delete WireGuard peer %s for interface %s: %v", id, deviceId, deleteReply.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// endregion wireguard-related
|
||||
|
||||
// region wg-quick-related
|
||||
|
||||
func (c *PfsenseController) ExecuteInterfaceHook(
|
||||
_ context.Context,
|
||||
_ domain.InterfaceIdentifier,
|
||||
_ string,
|
||||
) error {
|
||||
// TODO implement me
|
||||
slog.Error("interface hooks are not yet supported for pfSense backends, please open an issue on GitHub")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) SetDNS(
|
||||
ctx context.Context,
|
||||
_ domain.InterfaceIdentifier,
|
||||
dnsStr, _ string,
|
||||
) error {
|
||||
// Lock the interface to prevent concurrent modifications
|
||||
c.coreMutex.Lock()
|
||||
defer c.coreMutex.Unlock()
|
||||
|
||||
// pfSense DNS configuration is typically managed at the system level
|
||||
// This may need to be implemented based on pfSense API capabilities
|
||||
slog.Warn("DNS setting is not yet fully supported for pfSense backends")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) UnsetDNS(
|
||||
ctx context.Context,
|
||||
_ domain.InterfaceIdentifier,
|
||||
dnsStr, _ string,
|
||||
) error {
|
||||
// Lock the interface to prevent concurrent modifications
|
||||
c.coreMutex.Lock()
|
||||
defer c.coreMutex.Unlock()
|
||||
|
||||
// pfSense DNS configuration is typically managed at the system level
|
||||
slog.Warn("DNS unsetting is not yet fully supported for pfSense backends")
|
||||
return nil
|
||||
}
|
||||
|
||||
// endregion wg-quick-related
|
||||
|
||||
// region routing-related
|
||||
|
||||
func (c *PfsenseController) SetRoutes(_ context.Context, info domain.RoutingTableInfo) error {
|
||||
// pfSense routing is typically managed through the firewall rules and routing tables
|
||||
// This may need to be implemented based on pfSense API capabilities
|
||||
slog.Warn("route setting is not yet fully supported for pfSense backends")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PfsenseController) RemoveRoutes(_ context.Context, info domain.RoutingTableInfo) error {
|
||||
// pfSense routing is typically managed through the firewall rules and routing tables
|
||||
slog.Warn("route removal is not yet fully supported for pfSense backends")
|
||||
return nil
|
||||
}
|
||||
|
||||
// endregion routing-related
|
||||
|
||||
// region statistics-related
|
||||
|
||||
func (c *PfsenseController) PingAddresses(
|
||||
ctx context.Context,
|
||||
addr string,
|
||||
) (*domain.PingerResult, error) {
|
||||
// Use pfSense API to ping if available, otherwise return error
|
||||
// This may need to be implemented based on pfSense API capabilities
|
||||
return nil, fmt.Errorf("ping functionality is not yet implemented for pfSense backends")
|
||||
}
|
||||
|
||||
// endregion statistics-related
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
@@ -16,8 +17,9 @@ import (
|
||||
|
||||
// WgRepo implements all low-level WireGuard interactions.
|
||||
type WgRepo struct {
|
||||
wg lowlevel.WireGuardClient
|
||||
nl lowlevel.NetlinkClient
|
||||
wg lowlevel.WireGuardClient
|
||||
nl lowlevel.NetlinkClient
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewWireGuardRepository creates a new WgRepo instance.
|
||||
@@ -31,8 +33,9 @@ func NewWireGuardRepository() *WgRepo {
|
||||
nl := &lowlevel.NetlinkManager{}
|
||||
|
||||
repo := &WgRepo{
|
||||
wg: wg,
|
||||
nl: nl,
|
||||
wg: wg,
|
||||
nl: nl,
|
||||
log: slog.Default().With(slog.String("adapter", "wireguard")),
|
||||
}
|
||||
|
||||
return repo
|
||||
@@ -40,8 +43,10 @@ func NewWireGuardRepository() *WgRepo {
|
||||
|
||||
// GetInterfaces returns all existing WireGuard interfaces.
|
||||
func (r *WgRepo) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) {
|
||||
r.log.Debug("getting all interfaces")
|
||||
devices, err := r.wg.Devices()
|
||||
if err != nil {
|
||||
r.log.Error("failed to get devices", "error", err)
|
||||
return nil, fmt.Errorf("device list error: %w", err)
|
||||
}
|
||||
|
||||
@@ -60,14 +65,17 @@ func (r *WgRepo) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, e
|
||||
// GetInterface returns the interface with the given id.
|
||||
// If no interface is found, an error os.ErrNotExist is returned.
|
||||
func (r *WgRepo) GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) {
|
||||
r.log.Debug("getting interface", "id", id)
|
||||
return r.getInterface(id)
|
||||
}
|
||||
|
||||
// GetPeers returns all peers associated with the given interface id.
|
||||
// If the requested interface is found, an error os.ErrNotExist is returned.
|
||||
func (r *WgRepo) GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) {
|
||||
r.log.Debug("getting peers for interface", "deviceId", deviceId)
|
||||
device, err := r.wg.Device(string(deviceId))
|
||||
if err != nil {
|
||||
r.log.Error("failed to get device", "deviceId", deviceId, "error", err)
|
||||
return nil, fmt.Errorf("device error: %w", err)
|
||||
}
|
||||
|
||||
@@ -90,6 +98,7 @@ func (r *WgRepo) GetPeer(
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
) (*domain.PhysicalPeer, error) {
|
||||
r.log.Debug("getting peer", "deviceId", deviceId, "peerId", id)
|
||||
return r.getPeer(deviceId, id)
|
||||
}
|
||||
|
||||
@@ -174,25 +183,31 @@ func (r *WgRepo) SaveInterface(
|
||||
id domain.InterfaceIdentifier,
|
||||
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
|
||||
) error {
|
||||
r.log.Debug("saving interface", "id", id)
|
||||
physicalInterface, err := r.getOrCreateInterface(id)
|
||||
if err != nil {
|
||||
r.log.Error("failed to get or create interface", "id", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if updateFunc != nil {
|
||||
physicalInterface, err = updateFunc(physicalInterface)
|
||||
if err != nil {
|
||||
r.log.Error("interface update function failed", "id", id, "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.updateLowLevelInterface(physicalInterface); err != nil {
|
||||
r.log.Error("failed to update low level interface", "id", id, "error", err)
|
||||
return err
|
||||
}
|
||||
if err := r.updateWireGuardInterface(physicalInterface); err != nil {
|
||||
r.log.Error("failed to update wireguard interface", "id", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
r.log.Debug("successfully saved interface", "id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -323,10 +338,13 @@ func (r *WgRepo) updateWireGuardInterface(pi *domain.PhysicalInterface) error {
|
||||
// DeleteInterface deletes the interface with the given id.
|
||||
// If the requested interface is found, no error is returned.
|
||||
func (r *WgRepo) DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error {
|
||||
r.log.Debug("deleting interface", "id", id)
|
||||
if err := r.deleteLowLevelInterface(id); err != nil {
|
||||
r.log.Error("failed to delete low level interface", "id", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
r.log.Debug("successfully deleted interface", "id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -356,20 +374,25 @@ func (r *WgRepo) SavePeer(
|
||||
id domain.PeerIdentifier,
|
||||
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
|
||||
) error {
|
||||
r.log.Debug("saving peer", "deviceId", deviceId, "peerId", id)
|
||||
physicalPeer, err := r.getOrCreatePeer(deviceId, id)
|
||||
if err != nil {
|
||||
r.log.Error("failed to get or create peer", "deviceId", deviceId, "peerId", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
physicalPeer, err = updateFunc(physicalPeer)
|
||||
if err != nil {
|
||||
r.log.Error("peer update function failed", "deviceId", deviceId, "peerId", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.updatePeer(deviceId, physicalPeer); err != nil {
|
||||
r.log.Error("failed to update peer", "deviceId", deviceId, "peerId", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
r.log.Debug("successfully saved peer", "deviceId", deviceId, "peerId", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -441,6 +464,7 @@ func (r *WgRepo) updatePeer(deviceId domain.InterfaceIdentifier, pp *domain.Phys
|
||||
|
||||
err := r.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}})
|
||||
if err != nil {
|
||||
r.log.Error("failed to configure device for peer update", "deviceId", deviceId, "peerId", pp.Identifier, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -450,15 +474,20 @@ func (r *WgRepo) updatePeer(deviceId domain.InterfaceIdentifier, pp *domain.Phys
|
||||
// DeletePeer deletes the peer with the given id.
|
||||
// If the requested interface or peer is found, no error is returned.
|
||||
func (r *WgRepo) DeletePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error {
|
||||
r.log.Debug("deleting peer", "deviceId", deviceId, "peerId", id)
|
||||
if !id.IsPublicKey() {
|
||||
return errors.New("invalid public key")
|
||||
err := errors.New("invalid public key")
|
||||
r.log.Error("invalid peer id", "peerId", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err := r.deletePeer(deviceId, id)
|
||||
if err != nil {
|
||||
r.log.Error("failed to delete peer", "deviceId", deviceId, "peerId", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
r.log.Debug("successfully deleted peer", "deviceId", deviceId, "peerId", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -470,6 +499,7 @@ func (r *WgRepo) deletePeer(deviceId domain.InterfaceIdentifier, id domain.PeerI
|
||||
|
||||
err := r.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}})
|
||||
if err != nil {
|
||||
r.log.Error("failed to configure device for peer deletion", "deviceId", deviceId, "peerId", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -800,6 +800,126 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/bulk-delete": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Peer"
|
||||
],
|
||||
"summary": "Bulk delete selected peers.",
|
||||
"operationId": "peers_handleBulkDelete",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "A list of peer identifiers to delete",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No content if deletion was successful"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/bulk-disable": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Peer"
|
||||
],
|
||||
"summary": "Bulk disable selected peers.",
|
||||
"operationId": "peers_handleBulkDisable",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "A list of peer identifiers to disable",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No content if action was successful"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/bulk-enable": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Peer"
|
||||
],
|
||||
"summary": "Bulk enable selected peers.",
|
||||
"operationId": "peers_handleBulkEnable",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "A list of peer identifiers to enable",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No content if action was successful"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/config-mail": {
|
||||
"post": {
|
||||
"produces": [
|
||||
@@ -819,6 +939,12 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.PeerMailRequest"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The configuration style",
|
||||
"name": "style",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -858,6 +984,12 @@
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The configuration style",
|
||||
"name": "style",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -899,6 +1031,12 @@
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The configuration style",
|
||||
"name": "style",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -1306,6 +1444,206 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/bulk-delete": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Bulk delete selected users.",
|
||||
"operationId": "users_handleBulkDelete",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "A list of user identifiers to delete",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No content if deletion was successful"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/bulk-disable": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Bulk disable selected users.",
|
||||
"operationId": "users_handleBulkDisable",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "A list of user identifiers to disable",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No content if action was successful"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/bulk-enable": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Bulk enable selected users.",
|
||||
"operationId": "users_handleBulkEnable",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "A list of user identifiers to enable",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No content if action was successful"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/bulk-lock": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Bulk lock selected users.",
|
||||
"operationId": "users_handleBulkLock",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "A list of user identifiers to lock",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No content if action was successful"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/bulk-unlock": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Bulk unlock selected users.",
|
||||
"operationId": "users_handleBulkUnlock",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "A list of user identifiers to unlock",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.BulkPeerRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No content if action was successful"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/new": {
|
||||
"post": {
|
||||
"produces": [
|
||||
@@ -1532,6 +1870,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/{id}/change-password": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Change the password for the given user.",
|
||||
"operationId": "users_handleChangePasswordPost",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.User"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/{id}/interfaces": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -1687,6 +2057,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.BulkPeerRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"Identifiers"
|
||||
],
|
||||
"properties": {
|
||||
"Identifiers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Reason": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.ConfigOption-array_string": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1763,6 +2150,11 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Backend": {
|
||||
"description": "the backend used for this interface e.g., local, mikrotik, ...",
|
||||
"type": "string",
|
||||
"example": "local"
|
||||
},
|
||||
"Disabled": {
|
||||
"description": "flag that specifies if the interface is enabled (up) or not (down)",
|
||||
"type": "boolean"
|
||||
@@ -1951,7 +2343,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Suffix": {
|
||||
"Prefix": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -2136,6 +2528,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"UserDisplayName": {
|
||||
"description": "the owner display name",
|
||||
"type": "string"
|
||||
},
|
||||
"UserIdentifier": {
|
||||
"description": "the owner",
|
||||
"type": "string"
|
||||
@@ -2231,6 +2627,12 @@
|
||||
"ApiAdminOnly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"AvailableBackends": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.SettingsBackendNames"
|
||||
}
|
||||
},
|
||||
"LoginFormVisible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2251,6 +2653,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.SettingsBackendNames": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Id": {
|
||||
"type": "string"
|
||||
},
|
||||
"Name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.User": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2263,6 +2676,12 @@
|
||||
"ApiTokenCreated": {
|
||||
"type": "string"
|
||||
},
|
||||
"AuthSources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Department": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2306,14 +2725,11 @@
|
||||
"PeerCount": {
|
||||
"type": "integer"
|
||||
},
|
||||
"PersistLocalChanges": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"Phone": {
|
||||
"type": "string"
|
||||
},
|
||||
"ProviderName": {
|
||||
"type": "string"
|
||||
},
|
||||
"Source": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -16,6 +16,17 @@ definitions:
|
||||
Timestamp:
|
||||
type: string
|
||||
type: object
|
||||
model.BulkPeerRequest:
|
||||
properties:
|
||||
Identifiers:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
Reason:
|
||||
type: string
|
||||
required:
|
||||
- Identifiers
|
||||
type: object
|
||||
model.ConfigOption-array_string:
|
||||
properties:
|
||||
Overridable:
|
||||
@@ -65,6 +76,10 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
Backend:
|
||||
description: the backend used for this interface e.g., local, mikrotik, ...
|
||||
example: local
|
||||
type: string
|
||||
Disabled:
|
||||
description: flag that specifies if the interface is enabled (up) or not (down)
|
||||
type: boolean
|
||||
@@ -206,7 +221,7 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
Suffix:
|
||||
Prefix:
|
||||
type: string
|
||||
type: object
|
||||
model.Peer:
|
||||
@@ -318,6 +333,9 @@ definitions:
|
||||
allOf:
|
||||
- $ref: '#/definitions/model.ConfigOption-string'
|
||||
description: the routing table
|
||||
UserDisplayName:
|
||||
description: the owner display name
|
||||
type: string
|
||||
UserIdentifier:
|
||||
description: the owner
|
||||
type: string
|
||||
@@ -381,6 +399,10 @@ definitions:
|
||||
properties:
|
||||
ApiAdminOnly:
|
||||
type: boolean
|
||||
AvailableBackends:
|
||||
items:
|
||||
$ref: '#/definitions/model.SettingsBackendNames'
|
||||
type: array
|
||||
LoginFormVisible:
|
||||
type: boolean
|
||||
MailLinkOnly:
|
||||
@@ -394,6 +416,13 @@ definitions:
|
||||
WebAuthnEnabled:
|
||||
type: boolean
|
||||
type: object
|
||||
model.SettingsBackendNames:
|
||||
properties:
|
||||
Id:
|
||||
type: string
|
||||
Name:
|
||||
type: string
|
||||
type: object
|
||||
model.User:
|
||||
properties:
|
||||
ApiEnabled:
|
||||
@@ -402,6 +431,10 @@ definitions:
|
||||
type: string
|
||||
ApiTokenCreated:
|
||||
type: string
|
||||
AuthSources:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
Department:
|
||||
type: string
|
||||
Disabled:
|
||||
@@ -432,12 +465,10 @@ definitions:
|
||||
type: string
|
||||
PeerCount:
|
||||
type: integer
|
||||
PersistLocalChanges:
|
||||
type: boolean
|
||||
Phone:
|
||||
type: string
|
||||
ProviderName:
|
||||
type: string
|
||||
Source:
|
||||
type: string
|
||||
type: object
|
||||
model.WebAuthnCredentialRequest:
|
||||
properties:
|
||||
@@ -1062,6 +1093,84 @@ paths:
|
||||
summary: Update the given peer record.
|
||||
tags:
|
||||
- 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:
|
||||
post:
|
||||
operationId: peers_handleEmailPost
|
||||
@@ -1072,6 +1181,10 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/model.PeerMailRequest'
|
||||
- description: The configuration style
|
||||
in: query
|
||||
name: style
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1097,6 +1210,10 @@ paths:
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: The configuration style
|
||||
in: query
|
||||
name: style
|
||||
type: string
|
||||
produces:
|
||||
- image/png
|
||||
- application/json
|
||||
@@ -1125,6 +1242,10 @@ paths:
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: The configuration style
|
||||
in: query
|
||||
name: style
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1415,6 +1536,27 @@ paths:
|
||||
summary: Enable the REST API for the given user.
|
||||
tags:
|
||||
- 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:
|
||||
get:
|
||||
operationId: users_handleInterfacesGet
|
||||
@@ -1520,6 +1662,136 @@ paths:
|
||||
summary: Get all user records.
|
||||
tags:
|
||||
- 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:
|
||||
post:
|
||||
operationId: users_handleCreatePost
|
||||
|
||||
@@ -17,11 +17,6 @@
|
||||
"paths": {
|
||||
"/interface/all": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -52,16 +47,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/interface/by-id/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/interface/by-id/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -110,14 +105,14 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"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, ...).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -182,14 +177,14 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -241,16 +236,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/interface/new": {
|
||||
"post": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/interface/new": {
|
||||
"post": {
|
||||
"description": "This endpoint creates a new interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -308,16 +303,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/interface/prepare": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/interface/prepare": {
|
||||
"get": {
|
||||
"description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -352,16 +347,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/metrics/by-interface/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/metrics/by-interface/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -410,16 +405,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/metrics/by-peer/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/metrics/by-peer/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -468,16 +463,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/metrics/by-user/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/metrics/by-user/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -526,16 +521,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/by-id/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/peer/by-id/{id}": {
|
||||
"get": {
|
||||
"description": "Normal users can only access their own records. Admins can access all records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -585,14 +580,14 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"description": "Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -657,14 +652,14 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -716,16 +711,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/by-interface/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/peer/by-interface/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -765,16 +760,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/by-user/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/peer/by-user/{id}": {
|
||||
"get": {
|
||||
"description": "Normal users can only access their own records. Admins can access all records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -815,16 +810,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/new": {
|
||||
"post": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/peer/new": {
|
||||
"post": {
|
||||
"description": "Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -882,16 +877,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/prepare/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/peer/prepare/{id}": {
|
||||
"get": {
|
||||
"description": "This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -947,16 +942,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/provisioning/data/peer-config": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/provisioning/data/peer-config": {
|
||||
"get": {
|
||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||
"produces": [
|
||||
"text/plain",
|
||||
@@ -1013,16 +1008,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/provisioning/data/peer-qr": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/provisioning/data/peer-qr": {
|
||||
"get": {
|
||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||
"produces": [
|
||||
"image/png",
|
||||
@@ -1079,16 +1074,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/provisioning/data/user-info": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/provisioning/data/user-info": {
|
||||
"get": {
|
||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -1149,16 +1144,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/provisioning/new-peer": {
|
||||
"post": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/provisioning/new-peer": {
|
||||
"post": {
|
||||
"description": "Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -1216,16 +1211,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/all": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/user/all": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1256,16 +1251,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/by-id/{id}": {
|
||||
"get": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/user/by-id/{id}": {
|
||||
"get": {
|
||||
"description": "Normal users can only access their own record. Admins can access all records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -1315,14 +1310,14 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"description": "Only admins can update existing records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -1387,14 +1382,14 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1446,16 +1441,16 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/new": {
|
||||
"post": {
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"/user/new": {
|
||||
"post": {
|
||||
"description": "Only admins can create new records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -1513,7 +1508,12 @@
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2086,6 +2086,11 @@
|
||||
"InterfaceIdentifier"
|
||||
],
|
||||
"properties": {
|
||||
"DisplayName": {
|
||||
"description": "DisplayName is an optional name for the new peer.\nIf unset, a default template value (e.g., \"API Peer ...\") will be assigned.",
|
||||
"type": "string",
|
||||
"example": "API Peer xyz"
|
||||
},
|
||||
"InterfaceIdentifier": {
|
||||
"description": "InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to.",
|
||||
"type": "string",
|
||||
@@ -2127,6 +2132,22 @@
|
||||
"minLength": 32,
|
||||
"example": ""
|
||||
},
|
||||
"AuthSources": {
|
||||
"description": "The source of the user. This field is optional.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"db",
|
||||
"ldap",
|
||||
"oauth"
|
||||
]
|
||||
},
|
||||
"readOnly": true,
|
||||
"example": [
|
||||
"db"
|
||||
]
|
||||
},
|
||||
"Department": {
|
||||
"description": "The department of the user. This field is optional.",
|
||||
"type": "string",
|
||||
@@ -2200,20 +2221,6 @@
|
||||
"description": "The phone number of the user. This field is optional.",
|
||||
"type": "string",
|
||||
"example": "+1234546789"
|
||||
},
|
||||
"ProviderName": {
|
||||
"description": "The name of the authentication provider. This field is read-only.",
|
||||
"type": "string",
|
||||
"readOnly": true,
|
||||
"example": ""
|
||||
},
|
||||
"Source": {
|
||||
"description": "The source of the user. This field is optional.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"db"
|
||||
],
|
||||
"example": "db"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -445,6 +445,12 @@ definitions:
|
||||
type: object
|
||||
models.ProvisioningRequest:
|
||||
properties:
|
||||
DisplayName:
|
||||
description: |-
|
||||
DisplayName is an optional name for the new peer.
|
||||
If unset, a default template value (e.g., "API Peer ...") will be assigned.
|
||||
example: API Peer xyz
|
||||
type: string
|
||||
InterfaceIdentifier:
|
||||
description: InterfaceIdentifier is the identifier of the WireGuard interface
|
||||
the peer should be linked to.
|
||||
@@ -484,6 +490,18 @@ definitions:
|
||||
maxLength: 64
|
||||
minLength: 32
|
||||
type: string
|
||||
AuthSources:
|
||||
description: The source of the user. This field is optional.
|
||||
example:
|
||||
- db
|
||||
items:
|
||||
enum:
|
||||
- db
|
||||
- ldap
|
||||
- oauth
|
||||
type: string
|
||||
readOnly: true
|
||||
type: array
|
||||
Department:
|
||||
description: The department of the user. This field is optional.
|
||||
example: Software Development
|
||||
@@ -546,17 +564,6 @@ definitions:
|
||||
description: The phone number of the user. This field is optional.
|
||||
example: "+1234546789"
|
||||
type: string
|
||||
ProviderName:
|
||||
description: The name of the authentication provider. This field is read-only.
|
||||
example: ""
|
||||
readOnly: true
|
||||
type: string
|
||||
Source:
|
||||
description: The source of the user. This field is optional.
|
||||
enum:
|
||||
- db
|
||||
example: db
|
||||
type: string
|
||||
required:
|
||||
- Identifier
|
||||
type: object
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 709 B |
@@ -6,9 +6,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||
<title>WireGuard Portal API</title>
|
||||
<meta name="description" content="WireGuard Portal API">
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/app/favicon.ico">
|
||||
<link rel="stylesheet" href="{{$.BasePath}}/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="{{$.BasePath}}/fonts/fontawesome-all.min.css">
|
||||
<link rel="shortcut icon" type="image/x-icon" href="{{$.BasePath}}/app/favicon.ico">
|
||||
</head>
|
||||
|
||||
<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-body">
|
||||
<p class="card-text">This endpoint is used by the Single Page Application (the Frontend/UI).</p>
|
||||
<a href="/api/v0/doc.html" title="API version 0" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
|
||||
<a href="{{$.BasePath}}/api/v0/doc.html" title="API version 0" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="card-header"><b>Version 1</b></div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">This is the current main API endpoint.</p>
|
||||
<a href="/api/v1/doc.html" title="API version 1" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
|
||||
<a href="{{$.BasePath}}/api/v1/doc.html" title="API version 1" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,17 +43,17 @@
|
||||
<div class="card-header">Version 2</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">This will be a future API version, it is currently work in progress.</p>
|
||||
<a href="/api/v2/doc.html" title="API version 2" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
|
||||
<a href="{{$.BasePath}}/api/v2/doc.html" title="API version 2" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "prt_footer.gohtml" .}}
|
||||
<script src="/js/jquery.min.js"></script>
|
||||
<script src="/js/jquery.easing.js"></script>
|
||||
<script src="/js/popper.min.js"></script>
|
||||
<script src="/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{$.BasePath}}/js/jquery.min.js"></script>
|
||||
<script src="{{$.BasePath}}/js/jquery.easing.js"></script>
|
||||
<script src="{{$.BasePath}}/js/popper.min.js"></script>
|
||||
<script src="{{$.BasePath}}/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -3,7 +3,7 @@
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<a class="navbar-brand" href="/"><img src="/img/header-logo.png" alt="Prolicht"/></a>
|
||||
<a class="navbar-brand" href="/"><img src="{{$.BasePath}}/img/header-logo.png" alt="Prolicht"/></a>
|
||||
<div id="topNavbar" class="navbar-collapse collapse">
|
||||
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
|
||||
</ul>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
allow-spec-file-load="false"
|
||||
allow-spec-file-download="true"
|
||||
>
|
||||
<img slot="logo" src="/img/header-logo-small.png" style="width:50px; height:50px"/>
|
||||
<img slot="logo" src="{{$.BasePath}}/img/header-logo-small.png" style="width:50px; height:50px"/>
|
||||
|
||||
<p slot="footer" style="margin:0; padding:16px 36px; background-color:#f76b39; color:#fff; text-align:center;" >
|
||||
Copyright © WireGuard Portal {{$.Year}}, version {{$.Version}}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -38,6 +40,12 @@ func (w *writerWrapper) Write(data []byte) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Hijack wraps the Hijack method of the ResponseWriter and returns the hijacked connection.
|
||||
// This is required for websockets to work.
|
||||
func (w *writerWrapper) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return http.NewResponseController(w.ResponseWriter).Hijack()
|
||||
}
|
||||
|
||||
// newWriterWrapper returns a new writerWrapper that wraps the given http.ResponseWriter.
|
||||
// It initializes the StatusCode to http.StatusOK.
|
||||
func newWriterWrapper(w http.ResponseWriter) *writerWrapper {
|
||||
|
||||
@@ -4,10 +4,14 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/routegroup"
|
||||
@@ -35,6 +39,7 @@ type ApiEndpointSetupFunc func() (ApiVersion, GroupSetupFn)
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
server *routegroup.Bundle
|
||||
root *routegroup.Bundle // root is the web-root (potentially with path prefix)
|
||||
tpl *respond.TemplateRenderer
|
||||
versions map[ApiVersion]*routegroup.Bundle
|
||||
}
|
||||
@@ -75,13 +80,42 @@ func NewServer(cfg *config.Config, endpoints ...ApiEndpointSetupFunc) (*Server,
|
||||
template.Must(template.New("").ParseFS(apiTemplates, "assets/tpl/*.gohtml")),
|
||||
)
|
||||
|
||||
// Serve static files
|
||||
// Mount base path if configured
|
||||
s.root = s.server
|
||||
if s.cfg.Web.BasePath != "" {
|
||||
s.root = s.server.Mount(s.cfg.Web.BasePath)
|
||||
}
|
||||
|
||||
// Serve static files (under base path if configured)
|
||||
imgFs := http.FS(fsMust(fs.Sub(apiStatics, "assets/img")))
|
||||
s.server.HandleFiles("/css", http.FS(fsMust(fs.Sub(apiStatics, "assets/css"))))
|
||||
s.server.HandleFiles("/js", http.FS(fsMust(fs.Sub(apiStatics, "assets/js"))))
|
||||
s.server.HandleFiles("/img", imgFs)
|
||||
s.server.HandleFiles("/fonts", http.FS(fsMust(fs.Sub(apiStatics, "assets/fonts"))))
|
||||
s.server.HandleFiles("/doc", http.FS(fsMust(fs.Sub(apiStatics, "assets/doc"))))
|
||||
s.root.HandleFiles("/css", http.FS(fsMust(fs.Sub(apiStatics, "assets/css"))))
|
||||
s.root.HandleFiles("/js", http.FS(fsMust(fs.Sub(apiStatics, "assets/js"))))
|
||||
s.root.HandleFiles("/img", imgFs)
|
||||
s.root.HandleFiles("/fonts", http.FS(fsMust(fs.Sub(apiStatics, "assets/fonts"))))
|
||||
if cfg.Web.BasePath == "" {
|
||||
s.root.HandleFiles("/doc", http.FS(fsMust(fs.Sub(apiStatics, "assets/doc"))))
|
||||
} else {
|
||||
customV0File, _ := fs.ReadFile(fsMust(fs.Sub(apiStatics, "assets/doc")), "v0_swagger.yaml")
|
||||
customV1File, _ := fs.ReadFile(fsMust(fs.Sub(apiStatics, "assets/doc")), "v1_swagger.yaml")
|
||||
customV0File = []byte(strings.Replace(string(customV0File),
|
||||
"basePath: /api/v0", "basePath: "+cfg.Web.BasePath+"/api/v0", 1))
|
||||
customV1File = []byte(strings.Replace(string(customV1File),
|
||||
"basePath: /api/v1", "basePath: "+cfg.Web.BasePath+"/api/v1", 1))
|
||||
|
||||
s.root.HandleFunc("GET /doc/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == s.cfg.Web.BasePath+"/doc/v0_swagger.yaml" {
|
||||
respond.Data(w, http.StatusOK, "application/yaml", customV0File)
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Path == s.cfg.Web.BasePath+"/doc/v1_swagger.yaml" {
|
||||
respond.Data(w, http.StatusOK, "application/yaml", customV1File)
|
||||
return
|
||||
}
|
||||
|
||||
respond.Status(w, http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
// Setup routes
|
||||
s.setupRoutes(endpoints...)
|
||||
@@ -100,6 +134,7 @@ func (s *Server) Run(ctx context.Context, listenAddress string) {
|
||||
srvContext, cancelFn := context.WithCancel(ctx)
|
||||
go func() {
|
||||
var err error
|
||||
slog.Debug("starting server", "certFile", s.cfg.Web.CertFile, "keyFile", s.cfg.Web.KeyFile)
|
||||
if s.cfg.Web.CertFile != "" && s.cfg.Web.KeyFile != "" {
|
||||
err = srv.ListenAndServeTLS(s.cfg.Web.CertFile, s.cfg.Web.KeyFile)
|
||||
} else {
|
||||
@@ -125,58 +160,214 @@ func (s *Server) Run(ctx context.Context, listenAddress string) {
|
||||
}
|
||||
|
||||
func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
|
||||
s.server.HandleFunc("GET /api", s.landingPage)
|
||||
s.root.HandleFunc("GET /api", s.landingPage)
|
||||
s.versions = make(map[ApiVersion]*routegroup.Bundle)
|
||||
|
||||
for _, setupFunc := range endpoints {
|
||||
version, groupSetupFn := setupFunc()
|
||||
|
||||
if _, ok := s.versions[version]; !ok {
|
||||
s.versions[version] = s.server.Mount(fmt.Sprintf("/api/%s", version))
|
||||
s.versions[version] = s.root.Mount(fmt.Sprintf("/api/%s", version))
|
||||
|
||||
// OpenAPI documentation (via RapiDoc)
|
||||
s.versions[version].HandleFunc("GET /swagger/index.html", s.rapiDocHandler(version)) // Deprecated: old link
|
||||
s.versions[version].HandleFunc("GET /doc.html", s.rapiDocHandler(version))
|
||||
|
||||
groupSetupFn(s.versions[version])
|
||||
versionGroup := s.versions[version].Group()
|
||||
groupSetupFn(versionGroup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) setupFrontendRoutes() {
|
||||
// Serve static files
|
||||
s.server.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
respond.Redirect(w, r, http.StatusMovedPermanently, "/app")
|
||||
s.root.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
respond.Redirect(w, r, http.StatusMovedPermanently, s.cfg.Web.BasePath+"/app")
|
||||
})
|
||||
|
||||
s.server.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
respond.Redirect(w, r, http.StatusMovedPermanently, "/app/favicon.ico")
|
||||
s.root.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
respond.Redirect(w, r, http.StatusMovedPermanently, s.cfg.Web.BasePath+"/app/favicon.ico")
|
||||
})
|
||||
|
||||
s.server.HandleFiles("/app", http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
|
||||
// If a custom frontend path is configured, serve files from there when it contains content.
|
||||
// If the directory is empty or missing, populate it with the embedded frontend-dist content first.
|
||||
useEmbeddedFrontend := true
|
||||
if s.cfg.Web.FrontendFilePath != "" {
|
||||
if err := os.MkdirAll(s.cfg.Web.FrontendFilePath, 0755); err != nil {
|
||||
slog.Error("failed to create frontend base directory", "path", s.cfg.Web.FrontendFilePath, "error", err)
|
||||
} else {
|
||||
ok := true
|
||||
hasFiles, err := dirHasFiles(s.cfg.Web.FrontendFilePath)
|
||||
if err != nil {
|
||||
slog.Error("failed to check frontend base directory", "path", s.cfg.Web.FrontendFilePath, "error", err)
|
||||
ok = false
|
||||
}
|
||||
if !hasFiles && ok {
|
||||
embeddedFS := fsMust(fs.Sub(frontendStatics, "frontend-dist"))
|
||||
if err := copyEmbedDirToDisk(embeddedFS, s.cfg.Web.FrontendFilePath); err != nil {
|
||||
slog.Error("failed to populate frontend base directory from embedded assets",
|
||||
"path", s.cfg.Web.FrontendFilePath, "error", err)
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
// serve files from FS
|
||||
slog.Debug("serving frontend files from custom path", "path", s.cfg.Web.FrontendFilePath)
|
||||
useEmbeddedFrontend = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var fileServer http.Handler
|
||||
if useEmbeddedFrontend {
|
||||
fileServer = http.FileServer(http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
|
||||
} else {
|
||||
fileServer = http.FileServer(http.Dir(s.cfg.Web.FrontendFilePath))
|
||||
}
|
||||
fileServer = http.StripPrefix(s.cfg.Web.BasePath+"/app", fileServer)
|
||||
|
||||
// Modify index.html and CSS to include the correct base path.
|
||||
var customIndexFile, customCssFile []byte
|
||||
var customCssFileName string
|
||||
if s.cfg.Web.BasePath != "" {
|
||||
customIndexFile, customCssFile, customCssFileName = s.updateBasePathInFrontend(useEmbeddedFrontend)
|
||||
}
|
||||
|
||||
s.root.HandleFunc("GET /app/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// serve a custom index.html file with the correct base path applied
|
||||
if s.cfg.Web.BasePath != "" && r.URL.Path == s.cfg.Web.BasePath+"/app/" {
|
||||
respond.Data(w, http.StatusOK, "text/html", customIndexFile)
|
||||
return
|
||||
}
|
||||
|
||||
// serve a custom CSS file with the correct base path applied
|
||||
if s.cfg.Web.BasePath != "" && r.URL.Path == s.cfg.Web.BasePath+"/app/assets/"+customCssFileName {
|
||||
respond.Data(w, http.StatusOK, "text/css", customCssFile)
|
||||
return
|
||||
}
|
||||
|
||||
// pass all other requests to the file server
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) landingPage(w http.ResponseWriter, _ *http.Request) {
|
||||
s.tpl.HTML(w, http.StatusOK, "index.gohtml", respond.TplData{
|
||||
"Version": internal.Version,
|
||||
"Year": time.Now().Year(),
|
||||
"BasePath": s.cfg.Web.BasePath,
|
||||
"Version": internal.Version,
|
||||
"Year": time.Now().Year(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) rapiDocHandler(version ApiVersion) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
s.tpl.HTML(w, http.StatusOK, "rapidoc.gohtml", respond.TplData{
|
||||
"RapiDocSource": "/js/rapidoc-min.js",
|
||||
"ApiSpecUrl": fmt.Sprintf("/doc/%s_swagger.yaml", version),
|
||||
"RapiDocSource": s.cfg.Web.BasePath + "/js/rapidoc-min.js",
|
||||
"BasePath": s.cfg.Web.BasePath,
|
||||
"ApiSpecUrl": fmt.Sprintf("%s/doc/%s_swagger.yaml", s.cfg.Web.BasePath, version),
|
||||
"Version": internal.Version,
|
||||
"Year": time.Now().Year(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) updateBasePathInFrontend(useEmbeddedFrontend bool) ([]byte, []byte, string) {
|
||||
if s.cfg.Web.BasePath == "" {
|
||||
return nil, nil, "" // nothing to do
|
||||
}
|
||||
|
||||
var customIndexFile []byte
|
||||
if useEmbeddedFrontend {
|
||||
customIndexFile, _ = fs.ReadFile(fsMust(fs.Sub(frontendStatics, "frontend-dist")), "index.html")
|
||||
} else {
|
||||
customIndexFile, _ = os.ReadFile(filepath.Join(s.cfg.Web.FrontendFilePath, "index.html"))
|
||||
}
|
||||
newIndexStr := strings.ReplaceAll(string(customIndexFile), "src=\"/", "src=\""+s.cfg.Web.BasePath+"/")
|
||||
newIndexStr = strings.ReplaceAll(newIndexStr, "href=\"/", "href=\""+s.cfg.Web.BasePath+"/")
|
||||
|
||||
re := regexp.MustCompile(`/app/assets/(index-.+.css)`)
|
||||
match := re.FindStringSubmatch(newIndexStr)
|
||||
cssFileName := match[1]
|
||||
|
||||
var customCssFile []byte
|
||||
if useEmbeddedFrontend {
|
||||
customCssFile, _ = fs.ReadFile(fsMust(fs.Sub(frontendStatics, "frontend-dist")), "assets/"+cssFileName)
|
||||
} else {
|
||||
customCssFile, _ = os.ReadFile(filepath.Join(s.cfg.Web.FrontendFilePath, "/assets/", cssFileName))
|
||||
}
|
||||
newCssStr := strings.ReplaceAll(string(customCssFile), "/app/assets/", s.cfg.Web.BasePath+"/app/assets/")
|
||||
|
||||
return []byte(newIndexStr), []byte(newCssStr), cssFileName
|
||||
}
|
||||
|
||||
func fsMust(f fs.FS, err error) fs.FS {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// dirHasFiles returns true if the directory contains at least one file (non-directory).
|
||||
func dirHasFiles(dir string) (bool, error) {
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
// Read a few entries; if any entry exists, consider it having files/dirs.
|
||||
// We want to know if there is at least one file; if only subdirs exist, still treat as content.
|
||||
entries, err := d.Readdir(-1)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
// check recursively
|
||||
has, err := dirHasFiles(filepath.Join(dir, e.Name()))
|
||||
if err == nil && has {
|
||||
return true, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
// regular file
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// copyEmbedDirToDisk copies the contents of srcFS into dstDir on disk.
|
||||
func copyEmbedDirToDisk(srcFS fs.FS, dstDir string) error {
|
||||
return fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dstDir, path)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, 0755)
|
||||
}
|
||||
// ensure parent dir exists
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
// open source file
|
||||
f, err := srcFS.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
out, err := os.Create(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, f); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
@@ -27,12 +28,12 @@ type PeerServicePeerManager interface {
|
||||
}
|
||||
|
||||
type PeerServiceConfigFileManager interface {
|
||||
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
|
||||
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
|
||||
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||
}
|
||||
|
||||
type PeerServiceMailManager interface {
|
||||
SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error
|
||||
SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error
|
||||
}
|
||||
|
||||
// endregion dependencies
|
||||
@@ -95,18 +96,53 @@ func (p PeerService) DeletePeer(ctx context.Context, id domain.PeerIdentifier) e
|
||||
return p.peers.DeletePeer(ctx, id)
|
||||
}
|
||||
|
||||
func (p PeerService) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) {
|
||||
return p.configFile.GetPeerConfig(ctx, id)
|
||||
func (p PeerService) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) {
|
||||
return p.configFile.GetPeerConfig(ctx, id, style)
|
||||
}
|
||||
|
||||
func (p PeerService) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) {
|
||||
return p.configFile.GetPeerConfigQrCode(ctx, id)
|
||||
func (p PeerService) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (
|
||||
io.Reader,
|
||||
error,
|
||||
) {
|
||||
return p.configFile.GetPeerConfigQrCode(ctx, id, style)
|
||||
}
|
||||
|
||||
func (p PeerService) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error {
|
||||
return p.mailer.SendPeerEmail(ctx, linkOnly, peers...)
|
||||
func (p PeerService) SendPeerEmail(
|
||||
ctx context.Context,
|
||||
linkOnly bool,
|
||||
style string,
|
||||
peers ...domain.PeerIdentifier,
|
||||
) error {
|
||||
return p.mailer.SendPeerEmail(ctx, linkOnly, style, peers...)
|
||||
}
|
||||
|
||||
func (p PeerService) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) {
|
||||
return p.peers.GetPeerStats(ctx, id)
|
||||
}
|
||||
|
||||
func (p PeerService) BulkDelete(ctx context.Context, ids []domain.PeerIdentifier) error {
|
||||
for _, id := range ids {
|
||||
if err := p.peers.DeletePeer(ctx, id); err != nil {
|
||||
return fmt.Errorf("failed to delete peer %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p PeerService) BulkUpdate(ctx context.Context, ids []domain.PeerIdentifier, updateFn func(*domain.Peer)) error {
|
||||
for _, id := range ids {
|
||||
peer, err := p.peers.GetPeer(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get peer %s: %w", id, err)
|
||||
}
|
||||
|
||||
updateFn(peer)
|
||||
|
||||
if _, err := p.peers.UpdatePeer(ctx, peer); err != nil {
|
||||
return fmt.Errorf("failed to update peer %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
@@ -70,6 +73,50 @@ func (u UserService) DeactivateApi(ctx context.Context, id domain.UserIdentifier
|
||||
return u.users.DeactivateApi(ctx, id)
|
||||
}
|
||||
|
||||
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) {
|
||||
return u.wg.GetUserPeers(ctx, id)
|
||||
}
|
||||
@@ -81,3 +128,30 @@ func (u UserService) GetUserPeerStats(ctx context.Context, id domain.UserIdentif
|
||||
func (u UserService) GetUserInterfaces(ctx context.Context, id domain.UserIdentifier) ([]domain.Interface, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/routegroup"
|
||||
@@ -449,7 +448,17 @@ func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc {
|
||||
|
||||
// isValidReturnUrl checks if the given return URL matches the configured external URL of the application.
|
||||
func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
|
||||
if !strings.HasPrefix(returnUrl, e.cfg.Web.ExternalUrl) {
|
||||
expectedUrl, err := url.Parse(e.cfg.Web.ExternalUrl)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
returnUrlParsed, err := url.Parse(returnUrl)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if returnUrlParsed.Scheme != expectedUrl.Scheme || returnUrlParsed.Host != expectedUrl.Host {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -21,17 +21,23 @@ import (
|
||||
//go:embed frontend_config.js.gotpl
|
||||
var frontendJs embed.FS
|
||||
|
||||
type ControllerManager interface {
|
||||
GetControllerNames() []config.BackendBase
|
||||
}
|
||||
|
||||
type ConfigEndpoint struct {
|
||||
cfg *config.Config
|
||||
authenticator Authenticator
|
||||
controllerMgr ControllerManager
|
||||
|
||||
tpl *respond.TemplateRenderer
|
||||
}
|
||||
|
||||
func NewConfigEndpoint(cfg *config.Config, authenticator Authenticator) ConfigEndpoint {
|
||||
func NewConfigEndpoint(cfg *config.Config, authenticator Authenticator, ctrlMgr ControllerManager) ConfigEndpoint {
|
||||
ep := ConfigEndpoint{
|
||||
cfg: cfg,
|
||||
authenticator: authenticator,
|
||||
controllerMgr: ctrlMgr,
|
||||
tpl: respond.NewTemplateRenderer(template.Must(template.ParseFS(frontendJs,
|
||||
"frontend_config.js.gotpl"))),
|
||||
}
|
||||
@@ -61,7 +67,8 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
||||
// @Router /config/frontend.js [get]
|
||||
func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
backendUrl := fmt.Sprintf("%s/api/v0", e.cfg.Web.ExternalUrl)
|
||||
basePath := e.cfg.Web.BasePath
|
||||
backendUrl := fmt.Sprintf("%s%s/api/v0", e.cfg.Web.ExternalUrl, basePath)
|
||||
if request.Header(r, "x-wg-dev") != "" {
|
||||
referer := request.Header(r, "Referer")
|
||||
host := "localhost"
|
||||
@@ -70,12 +77,13 @@ func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
|
||||
if err == nil {
|
||||
host, port, _ = net.SplitHostPort(parsedReferer.Host)
|
||||
}
|
||||
backendUrl = fmt.Sprintf("http://%s:%s/api/v0", host,
|
||||
port) // override if request comes from frontend started with npm run dev
|
||||
backendUrl = fmt.Sprintf("http://%s:%s%s/api/v0", host,
|
||||
port, basePath) // override if request comes from frontend started with npm run dev
|
||||
}
|
||||
|
||||
e.tpl.Render(w, http.StatusOK, "frontend_config.js.gotpl", "text/javascript", map[string]any{
|
||||
"BackendUrl": backendUrl,
|
||||
"BasePath": basePath,
|
||||
"Version": internal.Version,
|
||||
"SiteTitle": e.cfg.Web.SiteTitle,
|
||||
"SiteCompanyName": e.cfg.Web.SiteCompanyName,
|
||||
@@ -96,13 +104,36 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sessionUser := domain.GetUserInfo(r.Context())
|
||||
|
||||
controllerFn := func() []model.SettingsBackendNames {
|
||||
controllers := e.controllerMgr.GetControllerNames()
|
||||
names := make([]model.SettingsBackendNames, 0, len(controllers))
|
||||
|
||||
for _, controller := range controllers {
|
||||
displayName := controller.GetDisplayName()
|
||||
if displayName == "" {
|
||||
displayName = controller.Id // fallback to ID if no display name is set
|
||||
}
|
||||
if controller.Id == config.LocalBackendName {
|
||||
displayName = "modals.interface-edit.backend.local" // use a localized string for the local backend
|
||||
}
|
||||
names = append(names, model.SettingsBackendNames{
|
||||
Id: controller.Id,
|
||||
Name: displayName,
|
||||
})
|
||||
}
|
||||
|
||||
return names
|
||||
|
||||
}
|
||||
|
||||
hasSocialLogin := len(e.cfg.Auth.OAuth) > 0 || len(e.cfg.Auth.OpenIDConnect) > 0 || e.cfg.Auth.WebAuthn.Enabled
|
||||
|
||||
// For anonymous users, we return the settings object with minimal information
|
||||
if sessionUser.Id == domain.CtxUnknownUserId || sessionUser.Id == "" {
|
||||
respond.JSON(w, http.StatusOK, model.Settings{
|
||||
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
|
||||
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
|
||||
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
|
||||
AvailableBackends: []model.SettingsBackendNames{}, // return an empty list instead of null
|
||||
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
|
||||
})
|
||||
} else {
|
||||
respond.JSON(w, http.StatusOK, model.Settings{
|
||||
@@ -112,7 +143,9 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
|
||||
ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly,
|
||||
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
|
||||
MinPasswordLength: e.cfg.Auth.MinPasswordLength,
|
||||
AvailableBackends: controllerFn(),
|
||||
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
|
||||
CreateDefaultPeer: e.cfg.Core.CreateDefaultPeer,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/routegroup"
|
||||
|
||||
@@ -34,13 +35,17 @@ type PeerService interface {
|
||||
// DeletePeer deletes the peer with the given id.
|
||||
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
|
||||
// GetPeerConfig returns the peer configuration for the given id.
|
||||
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
|
||||
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||
// GetPeerConfigQrCode returns the peer configuration as qr code for the given id.
|
||||
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
|
||||
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||
// SendPeerEmail sends the peer configuration via email.
|
||||
SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error
|
||||
SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error
|
||||
// GetPeerStats returns the peer stats for the given interface.
|
||||
GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error)
|
||||
// BulkDelete deletes multiple peers.
|
||||
BulkDelete(context.Context, []domain.PeerIdentifier) error
|
||||
// BulkUpdate modifies multiple peers.
|
||||
BulkUpdate(context.Context, []domain.PeerIdentifier, func(*domain.Peer)) error
|
||||
}
|
||||
|
||||
type PeerEndpoint struct {
|
||||
@@ -84,6 +89,9 @@ func (e PeerEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
||||
apiGroup.HandleFunc("GET /{id}", e.handleSingleGet())
|
||||
apiGroup.HandleFunc("PUT /{id}", e.handleUpdatePut())
|
||||
apiGroup.HandleFunc("DELETE /{id}", e.handleDelete())
|
||||
apiGroup.HandleFunc("POST /bulk-delete", e.handleBulkDelete())
|
||||
apiGroup.HandleFunc("POST /bulk-enable", e.handleBulkEnable())
|
||||
apiGroup.HandleFunc("POST /bulk-disable", e.handleBulkDisable())
|
||||
}
|
||||
|
||||
// handleAllGet returns a gorm Handler function.
|
||||
@@ -355,6 +363,7 @@ func (e PeerEndpoint) handleDelete() http.HandlerFunc {
|
||||
// @Summary Get peer configuration as string.
|
||||
// @Produce json
|
||||
// @Param id path string true "The peer identifier"
|
||||
// @Param style query string false "The configuration style"
|
||||
// @Success 200 {object} string
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
@@ -369,7 +378,9 @@ func (e PeerEndpoint) handleConfigGet() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
configTxt, err := e.peerService.GetPeerConfig(r.Context(), domain.PeerIdentifier(id))
|
||||
configStyle := e.getConfigStyle(r)
|
||||
|
||||
configTxt, err := e.peerService.GetPeerConfig(r.Context(), domain.PeerIdentifier(id), configStyle)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError, model.Error{
|
||||
Code: http.StatusInternalServerError, Message: err.Error(),
|
||||
@@ -397,6 +408,7 @@ func (e PeerEndpoint) handleConfigGet() http.HandlerFunc {
|
||||
// @Produce png
|
||||
// @Produce json
|
||||
// @Param id path string true "The peer identifier"
|
||||
// @Param style query string false "The configuration style"
|
||||
// @Success 200 {file} binary
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
@@ -411,7 +423,9 @@ func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
configQr, err := e.peerService.GetPeerConfigQrCode(r.Context(), domain.PeerIdentifier(id))
|
||||
configStyle := e.getConfigStyle(r)
|
||||
|
||||
configQr, err := e.peerService.GetPeerConfigQrCode(r.Context(), domain.PeerIdentifier(id), configStyle)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError, model.Error{
|
||||
Code: http.StatusInternalServerError, Message: err.Error(),
|
||||
@@ -438,6 +452,7 @@ func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc {
|
||||
// @Summary Send peer configuration via email.
|
||||
// @Produce json
|
||||
// @Param request body model.PeerMailRequest true "The peer mail request data"
|
||||
// @Param style query string false "The configuration style"
|
||||
// @Success 204 "No content if mail sending was successful"
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
@@ -460,11 +475,13 @@ func (e PeerEndpoint) handleEmailPost() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
configStyle := e.getConfigStyle(r)
|
||||
|
||||
peerIds := make([]domain.PeerIdentifier, len(req.Identifiers))
|
||||
for i := range req.Identifiers {
|
||||
peerIds[i] = domain.PeerIdentifier(req.Identifiers[i])
|
||||
}
|
||||
if err := e.peerService.SendPeerEmail(r.Context(), req.LinkOnly, peerIds...); err != nil {
|
||||
if err := e.peerService.SendPeerEmail(r.Context(), req.LinkOnly, configStyle, peerIds...); err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError,
|
||||
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||
return
|
||||
@@ -504,3 +521,122 @@ func (e PeerEndpoint) handleStatsGet() http.HandlerFunc {
|
||||
respond.JSON(w, http.StatusOK, model.NewPeerStats(e.cfg.Statistics.CollectPeerData, stats))
|
||||
}
|
||||
}
|
||||
|
||||
func (e PeerEndpoint) getConfigStyle(r *http.Request) string {
|
||||
configStyle := request.QueryDefault(r, "style", domain.ConfigStyleWgQuick)
|
||||
if configStyle != domain.ConfigStyleWgQuick && configStyle != domain.ConfigStyleRaw {
|
||||
configStyle = domain.ConfigStyleWgQuick // default to wg-quick style
|
||||
}
|
||||
return configStyle
|
||||
}
|
||||
|
||||
// handleBulkDelete returns a gorm Handler function.
|
||||
//
|
||||
// @ID peers_handleBulkDelete
|
||||
// @Tags Peer
|
||||
// @Summary Bulk delete selected peers.
|
||||
// @Produce json
|
||||
// @Param request body model.BulkPeerRequest true "A list of peer identifiers to delete"
|
||||
// @Success 204 "No content if deletion was successful"
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
// @Router /peer/bulk-delete [post]
|
||||
func (e PeerEndpoint) handleBulkDelete() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.BulkPeerRequest
|
||||
if err := request.BodyJson(r, &req); err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]domain.PeerIdentifier, len(req.Identifiers))
|
||||
for i, id := range req.Identifiers {
|
||||
ids[i] = domain.PeerIdentifier(id)
|
||||
}
|
||||
|
||||
err := e.peerService.BulkDelete(r.Context(), ids)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError,
|
||||
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.Status(w, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBulkEnable returns a gorm Handler function.
|
||||
//
|
||||
// @ID peers_handleBulkEnable
|
||||
// @Tags Peer
|
||||
// @Summary Bulk enable selected peers.
|
||||
// @Produce json
|
||||
// @Param request body model.BulkPeerRequest true "A list of peer identifiers to enable"
|
||||
// @Success 204 "No content if action was successful"
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
// @Router /peer/bulk-enable [post]
|
||||
func (e PeerEndpoint) handleBulkEnable() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.BulkPeerRequest
|
||||
if err := request.BodyJson(r, &req); err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]domain.PeerIdentifier, len(req.Identifiers))
|
||||
for i, id := range req.Identifiers {
|
||||
ids[i] = domain.PeerIdentifier(id)
|
||||
}
|
||||
|
||||
err := e.peerService.BulkUpdate(r.Context(), ids, func(p *domain.Peer) {
|
||||
p.Disabled = nil
|
||||
})
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError,
|
||||
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.Status(w, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBulkDisable returns a gorm Handler function.
|
||||
//
|
||||
// @ID peers_handleBulkDisable
|
||||
// @Tags Peer
|
||||
// @Summary Bulk disable selected peers.
|
||||
// @Produce json
|
||||
// @Param request body model.BulkPeerRequest true "A list of peer identifiers to disable"
|
||||
// @Success 204 "No content if action was successful"
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
// @Router /peer/bulk-disable [post]
|
||||
func (e PeerEndpoint) handleBulkDisable() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.BulkPeerRequest
|
||||
if err := request.BodyJson(r, &req); err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]domain.PeerIdentifier, len(req.Identifiers))
|
||||
for i, id := range req.Identifiers {
|
||||
ids[i] = domain.PeerIdentifier(id)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
err := e.peerService.BulkUpdate(r.Context(), ids, func(p *domain.Peer) {
|
||||
p.Disabled = &now
|
||||
p.DisabledReason = domain.DisabledReasonAdmin
|
||||
})
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError,
|
||||
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.Status(w, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/routegroup"
|
||||
|
||||
@@ -28,12 +29,18 @@ type UserService interface {
|
||||
ActivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
||||
// DeactivateApi disables the API for the user with the given id.
|
||||
DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
||||
// ChangePassword changes the password for the user with the given id.
|
||||
ChangePassword(ctx context.Context, id domain.UserIdentifier, oldPassword, newPassword string) (*domain.User, error)
|
||||
// GetUserPeers returns all peers for the given user.
|
||||
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
|
||||
// GetUserPeerStats returns all peer stats for the given user.
|
||||
GetUserPeerStats(ctx context.Context, id domain.UserIdentifier) ([]domain.PeerStatus, error)
|
||||
// GetUserInterfaces returns all interfaces for the given user.
|
||||
GetUserInterfaces(ctx context.Context, id domain.UserIdentifier) ([]domain.Interface, error)
|
||||
// BulkDelete deletes multiple users.
|
||||
BulkDelete(ctx context.Context, ids []domain.UserIdentifier) error
|
||||
// BulkUpdate modifies multiple users.
|
||||
BulkUpdate(ctx context.Context, ids []domain.UserIdentifier, updateFn func(*domain.User)) error
|
||||
}
|
||||
|
||||
type UserEndpoint struct {
|
||||
@@ -75,6 +82,13 @@ func (e UserEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
||||
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet())
|
||||
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost())
|
||||
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost())
|
||||
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/change-password",
|
||||
e.handleChangePasswordPost())
|
||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-delete", e.handleBulkDelete())
|
||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-enable", e.handleBulkEnable())
|
||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-disable", e.handleBulkDisable())
|
||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-lock", e.handleBulkLock())
|
||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-unlock", e.handleBulkUnlock())
|
||||
}
|
||||
|
||||
// handleAllGet returns a gorm Handler function.
|
||||
@@ -391,3 +405,255 @@ func (e UserEndpoint) handleApiDisablePost() http.HandlerFunc {
|
||||
respond.JSON(w, http.StatusOK, model.NewUser(user, false))
|
||||
}
|
||||
}
|
||||
|
||||
// handleChangePasswordPost returns a gorm Handler function.
|
||||
//
|
||||
// @ID users_handleChangePasswordPost
|
||||
// @Tags Users
|
||||
// @Summary Change the password for the given user.
|
||||
// @Produce json
|
||||
// @Success 200 {object} model.User
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
// @Router /user/{id}/change-password [post]
|
||||
func (e UserEndpoint) handleChangePasswordPost() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userId := Base64UrlDecode(request.Path(r, "id"))
|
||||
if userId == "" {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
var passwordData struct {
|
||||
OldPassword string `json:"OldPassword"`
|
||||
Password string `json:"Password"`
|
||||
PasswordRepeat string `json:"PasswordRepeat"`
|
||||
}
|
||||
if err := request.BodyJson(r, &passwordData); err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if passwordData.OldPassword == "" {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "old password missing"})
|
||||
return
|
||||
}
|
||||
|
||||
if passwordData.Password == "" {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "new password missing"})
|
||||
return
|
||||
}
|
||||
|
||||
if passwordData.OldPassword == passwordData.Password {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "password did not change"})
|
||||
return
|
||||
}
|
||||
|
||||
if passwordData.Password != passwordData.PasswordRepeat {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "password mismatch"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := e.userService.ChangePassword(r.Context(), domain.UserIdentifier(userId),
|
||||
passwordData.OldPassword, passwordData.Password)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError,
|
||||
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, model.NewUser(user, false))
|
||||
}
|
||||
}
|
||||
|
||||
// handleBulkDelete returns a gorm Handler function.
|
||||
//
|
||||
// @ID users_handleBulkDelete
|
||||
// @Tags Users
|
||||
// @Summary Bulk delete selected users.
|
||||
// @Produce json
|
||||
// @Param request body model.BulkPeerRequest true "A list of user identifiers to delete"
|
||||
// @Success 204 "No content if deletion was successful"
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
// @Router /user/bulk-delete [post]
|
||||
func (e UserEndpoint) handleBulkDelete() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.BulkUserRequest
|
||||
if err := request.BodyJson(r, &req); err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]domain.UserIdentifier, len(req.Identifiers))
|
||||
for i, id := range req.Identifiers {
|
||||
ids[i] = domain.UserIdentifier(id)
|
||||
}
|
||||
|
||||
err := e.userService.BulkDelete(r.Context(), ids)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError,
|
||||
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.Status(w, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBulkEnable returns a gorm Handler function.
|
||||
//
|
||||
// @ID users_handleBulkEnable
|
||||
// @Tags Users
|
||||
// @Summary Bulk enable selected users.
|
||||
// @Produce json
|
||||
// @Param request body model.BulkPeerRequest true "A list of user identifiers to enable"
|
||||
// @Success 204 "No content if action was successful"
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
// @Router /user/bulk-enable [post]
|
||||
func (e UserEndpoint) handleBulkEnable() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.BulkUserRequest
|
||||
if err := request.BodyJson(r, &req); err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]domain.UserIdentifier, len(req.Identifiers))
|
||||
for i, id := range req.Identifiers {
|
||||
ids[i] = domain.UserIdentifier(id)
|
||||
}
|
||||
|
||||
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
|
||||
user.Disabled = nil
|
||||
})
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError,
|
||||
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.Status(w, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBulkDisable returns a gorm Handler function.
|
||||
//
|
||||
// @ID users_handleBulkDisable
|
||||
// @Tags Users
|
||||
// @Summary Bulk disable selected users.
|
||||
// @Produce json
|
||||
// @Param request body model.BulkPeerRequest true "A list of user identifiers to disable"
|
||||
// @Success 204 "No content if action was successful"
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
// @Router /user/bulk-disable [post]
|
||||
func (e UserEndpoint) handleBulkDisable() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.BulkUserRequest
|
||||
if err := request.BodyJson(r, &req); err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]domain.UserIdentifier, len(req.Identifiers))
|
||||
for i, id := range req.Identifiers {
|
||||
ids[i] = domain.UserIdentifier(id)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
|
||||
user.Disabled = &now
|
||||
user.DisabledReason = domain.DisabledReasonAdmin
|
||||
})
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError,
|
||||
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.Status(w, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBulkLock returns a gorm Handler function.
|
||||
//
|
||||
// @ID users_handleBulkLock
|
||||
// @Tags Users
|
||||
// @Summary Bulk lock selected users.
|
||||
// @Produce json
|
||||
// @Param request body model.BulkPeerRequest true "A list of user identifiers to lock"
|
||||
// @Success 204 "No content if action was successful"
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
// @Router /user/bulk-lock [post]
|
||||
func (e UserEndpoint) handleBulkLock() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.BulkUserRequest
|
||||
if err := request.BodyJson(r, &req); err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]domain.UserIdentifier, len(req.Identifiers))
|
||||
for i, id := range req.Identifiers {
|
||||
ids[i] = domain.UserIdentifier(id)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
|
||||
user.Locked = &now
|
||||
user.LockedReason = domain.LockedReasonAdmin
|
||||
})
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError,
|
||||
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.Status(w, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBulkUnlock returns a gorm Handler function.
|
||||
//
|
||||
// @ID users_handleBulkUnlock
|
||||
// @Tags Users
|
||||
// @Summary Bulk unlock selected users.
|
||||
// @Produce json
|
||||
// @Param request body model.BulkPeerRequest true "A list of user identifiers to unlock"
|
||||
// @Success 204 "No content if action was successful"
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
// @Router /user/bulk-unlock [post]
|
||||
func (e UserEndpoint) handleBulkUnlock() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.BulkUserRequest
|
||||
if err := request.BodyJson(r, &req); err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]domain.UserIdentifier, len(req.Identifiers))
|
||||
for i, id := range req.Identifiers {
|
||||
ids[i] = domain.UserIdentifier(id)
|
||||
}
|
||||
|
||||
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
|
||||
user.Locked = nil
|
||||
})
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError,
|
||||
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.Status(w, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
100
internal/app/api/v0/handlers/endpoint_websocket.go
Normal file
100
internal/app/api/v0/handlers/endpoint_websocket.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-pkgz/routegroup"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/app"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
type WebsocketEventBus interface {
|
||||
Subscribe(topic string, fn any) error
|
||||
Unsubscribe(topic string, fn any) error
|
||||
}
|
||||
|
||||
type WebsocketEndpoint struct {
|
||||
authenticator Authenticator
|
||||
bus WebsocketEventBus
|
||||
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
func NewWebsocketEndpoint(cfg *config.Config, auth Authenticator, bus WebsocketEventBus) *WebsocketEndpoint {
|
||||
return &WebsocketEndpoint{
|
||||
authenticator: auth,
|
||||
bus: bus,
|
||||
upgrader: websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
return strings.HasPrefix(origin, cfg.Web.ExternalUrl)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e WebsocketEndpoint) GetName() string {
|
||||
return "WebsocketEndpoint"
|
||||
}
|
||||
|
||||
func (e WebsocketEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
||||
g.With(e.authenticator.LoggedIn()).HandleFunc("GET /ws", e.handleWebsocket())
|
||||
}
|
||||
|
||||
// wsMessage represents a message sent over websocket to the frontend
|
||||
type wsMessage struct {
|
||||
Type string `json:"type"` // either "peer_stats" or "interface_stats"
|
||||
Data any `json:"data"` // domain.TrafficDelta
|
||||
}
|
||||
|
||||
func (e WebsocketEndpoint) handleWebsocket() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := e.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
defer cancel()
|
||||
|
||||
writeMutex := sync.Mutex{}
|
||||
writeJSON := func(msg wsMessage) error {
|
||||
writeMutex.Lock()
|
||||
defer writeMutex.Unlock()
|
||||
return conn.WriteJSON(msg)
|
||||
}
|
||||
|
||||
peerStatsHandler := func(status domain.TrafficDelta) {
|
||||
_ = writeJSON(wsMessage{Type: "peer_stats", Data: status})
|
||||
}
|
||||
interfaceStatsHandler := func(status domain.TrafficDelta) {
|
||||
_ = writeJSON(wsMessage{Type: "interface_stats", Data: status})
|
||||
}
|
||||
|
||||
_ = e.bus.Subscribe(app.TopicPeerStatsUpdated, peerStatsHandler)
|
||||
defer e.bus.Unsubscribe(app.TopicPeerStatsUpdated, peerStatsHandler)
|
||||
_ = e.bus.Subscribe(app.TopicInterfaceStatsUpdated, interfaceStatsHandler)
|
||||
defer e.bus.Unsubscribe(app.TopicInterfaceStatsUpdated, interfaceStatsHandler)
|
||||
|
||||
// Keep connection open until client disconnects or context is cancelled
|
||||
go func() {
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
WGPORTAL_BACKEND_BASE_URL="{{ $.BackendUrl }}";
|
||||
WGPORTAL_BASE_PATH="{{ $.BasePath }}";
|
||||
WGPORTAL_VERSION="{{ $.Version }}";
|
||||
WGPORTAL_SITE_TITLE="{{ $.SiteTitle }}";
|
||||
WGPORTAL_SITE_COMPANY_NAME="{{ $.SiteCompanyName }}";
|
||||
|
||||
@@ -49,7 +49,11 @@ func NewSessionWrapper(cfg *config.Config) *SessionWrapper {
|
||||
sessionManager.Cookie.Secure = strings.HasPrefix(cfg.Web.ExternalUrl, "https")
|
||||
sessionManager.Cookie.HttpOnly = true
|
||||
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
|
||||
sessionManager.Cookie.Path = "/"
|
||||
if cfg.Web.BasePath != "" {
|
||||
sessionManager.Cookie.Path = cfg.Web.BasePath
|
||||
} else {
|
||||
sessionManager.Cookie.Path = "/"
|
||||
}
|
||||
sessionManager.Cookie.Persist = false
|
||||
|
||||
wrappedSessionManager := &SessionWrapper{sessionManager}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user