mirror of
https://github.com/h44z/wg-portal.git
synced 2025-10-05 16:06:17 +00:00
Compare commits
85 Commits
v2.0.0-rc.
...
v2.1.0-rc.
Author | SHA1 | Date | |
---|---|---|---|
|
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 | ||
|
dd28a8dddf | ||
|
f994700caf | ||
|
be29abd29a | ||
|
94785c10ec | ||
|
3a732fd3e5 | ||
|
f0be66aea4 | ||
|
cbf8c5bca9 | ||
|
b6bfa1f6de | ||
|
0c8d6223ce | ||
|
e3b65ca337 | ||
|
61d8aa6589 | ||
|
7fd2bbad02 | ||
|
75a5f3d815 | ||
|
e9005b1b90 | ||
|
8816165260 | ||
|
ab9995350f | ||
|
7df4e4b813 | ||
|
657c4307b3 | ||
|
b918fb6522 | ||
|
78deede360 | ||
|
a8fb4365cf | ||
|
1394be2341 | ||
|
0102588d23 | ||
|
6a96925be7 | ||
|
f018babca7 | ||
|
c6253e7c15 | ||
|
2a1d82251e | ||
|
99d6ce73ad | ||
|
3eb84f0ee9 | ||
|
d8a57edef9 | ||
|
8271dd7c1f | ||
|
4ca37089bc | ||
|
8e5d5138c0 | ||
|
c73286e11a | ||
|
b4aa6f8ef3 | ||
|
432c627f9b | ||
|
cd60761ea7 | ||
|
2c8304417b | ||
|
020ebb64e7 | ||
|
923d4a6188 | ||
|
2b46dca770 | ||
|
b9c4ca04f5 |
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]
|
5
.github/dependabot.yml
vendored
5
.github/dependabot.yml
vendored
@@ -28,3 +28,8 @@ updates:
|
||||
patch:
|
||||
update-types:
|
||||
- patch
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
6
.github/workflows/chart.yml
vendored
6
.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@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -35,7 +35,7 @@ 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@v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
|
14
.github/workflows/docker-publish.yml
vendored
14
.github/workflows/docker-publish.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
push:
|
||||
branches: [master, stable]
|
||||
branches: [master]
|
||||
# Publish vX.X.X tags as releases.
|
||||
tags: ["v*.*.*"]
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -64,12 +64,8 @@ jobs:
|
||||
# major and major.minor tags are not available for alpha or beta releases
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{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') }}
|
||||
# set latest tag for default branch
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=semver,pattern=v{{major}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -114,7 +110,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download binaries
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: binaries
|
||||
|
||||
|
19
.github/workflows/pages.yml
vendored
19
.github/workflows/pages.yml
vendored
@@ -2,7 +2,11 @@ name: github-pages
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ["v*"]
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!v*-alpha*'
|
||||
- '!v*-beta*'
|
||||
- '!v*-rc*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -11,11 +15,11 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
|
||||
@@ -23,7 +27,14 @@ jobs:
|
||||
run: pip install mike mkdocs-material[imaging] mkdocs-minify-plugin mkdocs-swagger-ui-tag
|
||||
|
||||
- name: Publish documentation
|
||||
run: mike deploy --push --update-aliases ${{ github.ref_name }} latest
|
||||
if: ${{ ! startsWith(github.ref, 'refs/tags/') }}
|
||||
run: mike deploy --push ${{ github.ref_name }}
|
||||
env:
|
||||
GIT_COMMITTER_NAME: "github-actions[bot]"
|
||||
GIT_COMMITTER_EMAIL: "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
- name: Publish latest documentation
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
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"
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ ssh.key
|
||||
wg_portal.db
|
||||
sqlite.db
|
||||
/config.yml
|
||||
/config.yaml
|
||||
/config/
|
||||
venv/
|
||||
.cache/
|
||||
|
@@ -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.19
|
||||
FROM alpine:3.22
|
||||
# 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
|
||||
|
35
README.md
35
README.md
@@ -1,4 +1,4 @@
|
||||
# WireGuard Portal (v2 - testing)
|
||||
# WireGuard Portal v2
|
||||
|
||||
[](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
@@ -8,14 +8,6 @@
|
||||

|
||||
[](https://hub.docker.com/r/wgportal/wg-portal/)
|
||||
|
||||
> [!CAUTION]
|
||||
> Version 2 is currently under development and may contain bugs and breaking changes.
|
||||
> It is not advised to use this version in production. Use version [v1](https://github.com/h44z/wg-portal/tree/stable) instead.
|
||||
|
||||
> [!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).
|
||||
> Please update the Docker image from **h44z/wg-portal** to **wgportal/wg-portal**.
|
||||
|
||||
## Introduction
|
||||
<!-- Text from this line # is included in docs/documentation/overview.md -->
|
||||
**WireGuard Portal** is a simple, web-based configuration portal for [WireGuard](https://wireguard.com) server management.
|
||||
@@ -23,28 +15,29 @@ The portal uses the WireGuard [wgctrl](https://github.com/WireGuard/wgctrl-go) l
|
||||
interfaces. This allows for the seamless activation or deactivation of new users without disturbing existing VPN
|
||||
connections.
|
||||
|
||||
The configuration portal supports using a database (SQLite, MySQL, MsSQL or Postgres), OAuth or LDAP
|
||||
The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Postgres), OAuth or LDAP
|
||||
(Active Directory or OpenLDAP) as a user source for authentication and profile data.
|
||||
|
||||
## 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
|
||||
* Enable / Disable clients seamlessly
|
||||
* Generation of wg-quick configuration file (`wgX.conf`) if required
|
||||
* User authentication (database, OAuth, or LDAP)
|
||||
* User authentication (database, OAuth, or LDAP), Passkey support
|
||||
* IPv6 ready
|
||||
* Docker ready
|
||||
* Can be used with existing WireGuard setups
|
||||
* Support for multiple WireGuard interfaces
|
||||
* Supports multiple WireGuard backends (wgctrl or MikroTik)
|
||||
* Peer Expiry Feature
|
||||
* Handles route and DNS settings like wg-quick does
|
||||
* Exposes Prometheus metrics for monitoring and alerting
|
||||
* REST API for management and client deployment
|
||||
* Webhook for custom actions on peer, interface or user updates
|
||||
* Webhook for custom actions on peer, interface, or user updates
|
||||
|
||||
<!-- Text to this line # is included in docs/documentation/overview.md -->
|
||||

|
||||
@@ -68,3 +61,19 @@ For the complete documentation visit [wgportal.org](https://wgportal.org).
|
||||
## License
|
||||
|
||||
* 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).
|
||||
> Please update the Docker image from **h44z/wg-portal** to **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
|
||||
|
||||
|
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
evbus "github.com/vardius/message-bus"
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/adapters"
|
||||
@@ -41,13 +42,16 @@ func main() {
|
||||
|
||||
cfg.LogStartupValues()
|
||||
|
||||
dbEncryptedSerializer := app.NewGormEncryptedStringSerializer(cfg.Database.EncryptionPassphrase)
|
||||
schema.RegisterSerializer("encstr", dbEncryptedSerializer)
|
||||
rawDb, err := adapters.NewDatabase(cfg.Database)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
database, err := adapters.NewSqlRepository(rawDb)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
wireGuard := adapters.NewWireGuardRepository()
|
||||
wireGuard, err := wireguard.NewControllerManager(cfg)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
wgQuick := adapters.NewWgQuickRepo()
|
||||
|
||||
@@ -84,6 +88,10 @@ func main() {
|
||||
|
||||
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, 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)
|
||||
internal.AssertNoError(err)
|
||||
@@ -121,12 +129,13 @@ func main() {
|
||||
apiV0BackendInterfaces := backendV0.NewInterfaceService(cfg, wireGuardManager, cfgFileManager)
|
||||
apiV0BackendPeers := backendV0.NewPeerService(cfg, wireGuardManager, cfgFileManager, mailManager)
|
||||
|
||||
apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator)
|
||||
apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator,
|
||||
webAuthn)
|
||||
apiV0EndpointAudit := handlersV0.NewAuditEndpoint(cfg, apiV0Auth, auditManager)
|
||||
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)
|
||||
|
||||
apiFrontend := handlersV0.NewRestApi(apiV0Session,
|
||||
|
@@ -1,7 +1,7 @@
|
||||
---
|
||||
services:
|
||||
wg-portal:
|
||||
image: wgportal/wg-portal:latest
|
||||
image: wgportal/wg-portal:v2
|
||||
container_name: wg-portal
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
@@ -10,8 +10,10 @@ services:
|
||||
max-file: "3"
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
# Use host network mode for WireGuard and the UI. Ensure that access to the UI is properly secured.
|
||||
network_mode: "host"
|
||||
volumes:
|
||||
# left side is the host path, right side is the container path
|
||||
- /etc/wireguard:/etc/wireguard
|
||||
- ./data:/app/data
|
||||
- ./config:/app/config
|
||||
|
BIN
docs/assets/images/interface_view.png
Normal file
BIN
docs/assets/images/interface_view.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 134 KiB |
BIN
docs/assets/images/landing_page.png
Normal file
BIN
docs/assets/images/landing_page.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 110 KiB |
BIN
docs/assets/images/passkey_setup.png
Normal file
BIN
docs/assets/images/passkey_setup.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 106 KiB |
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: 130 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: 129 KiB |
@@ -11,11 +11,29 @@ core:
|
||||
create_default_peer: true
|
||||
self_provisioning_allowed: true
|
||||
|
||||
backend:
|
||||
# default backend decides where new interfaces are created
|
||||
default: mikrotik
|
||||
|
||||
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
|
||||
listening_address: :8080
|
||||
external_url: https://my.externa-domain.com
|
||||
external_url: https://my.external-domain.com
|
||||
csrf_secret: super-s3cr3t-csrf
|
||||
session_secret: super-s3cr3t-session
|
||||
request_logging: true
|
||||
@@ -31,6 +49,11 @@ database:
|
||||
debug: true
|
||||
type: sqlite
|
||||
dsn: data/sqlite.db
|
||||
encryption_passphrase: change-this-s3cr3t-encryption-passphrase
|
||||
|
||||
auth:
|
||||
webauthn:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
## LDAP Authentication and Synchronization
|
||||
@@ -71,7 +94,8 @@ auth:
|
||||
|
||||
auth:
|
||||
oidc:
|
||||
# a sample Entra ID provider with environment variable substitution
|
||||
# A sample Entra ID provider with environment variable substitution.
|
||||
# Only users with an @outlook.com email address are allowed to register or login.
|
||||
- id: azure
|
||||
provider_name: azure
|
||||
display_name: Login with</br>Entra ID
|
||||
@@ -79,6 +103,8 @@ auth:
|
||||
base_url: "https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0"
|
||||
client_id: "${AZURE_CLIENT_ID}"
|
||||
client_secret: "${AZURE_CLIENT_SECRET}"
|
||||
allowed_domains:
|
||||
- "outlook.com"
|
||||
extra_scopes:
|
||||
- profile
|
||||
- email
|
||||
@@ -187,3 +213,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)).
|
||||
|
@@ -1,10 +1,10 @@
|
||||
This page provides an overview of **all available configuration options** for WireGuard Portal.
|
||||
|
||||
You can supply these configurations in a **YAML** file (e.g. `config.yaml`) when starting the Portal.
|
||||
The path of the configuration file defaults to **config/config.yml** in the working directory of the executable.
|
||||
It is possible to override configuration filepath using the environment variable `WG_PORTAL_CONFIG`.
|
||||
You can supply these configurations in a **YAML** file when starting the Portal.
|
||||
The path of the configuration file defaults to `config/config.yaml` (or `config/config.yml`) in the working directory of the executable.
|
||||
It is possible to override the configuration filepath using the environment variable `WG_PORTAL_CONFIG`.
|
||||
For example: `WG_PORTAL_CONFIG=/etc/wg-portal/config.yaml ./wg-portal`.
|
||||
Also, environment variable substitution in config file is supported. Refer to [syntax](https://github.com/a8m/envsubst?tab=readme-ov-file#docs).
|
||||
Also, environment variable substitution in the config file is supported. Refer to the [syntax](https://github.com/a8m/envsubst?tab=readme-ov-file#docs).
|
||||
|
||||
Configuration examples are available on the [Examples](./examples.md) page.
|
||||
|
||||
@@ -14,7 +14,9 @@ Configuration examples are available on the [Examples](./examples.md) page.
|
||||
```yaml
|
||||
core:
|
||||
admin_user: admin@wgportal.local
|
||||
admin_password: wgportal
|
||||
admin_password: wgportal-default
|
||||
admin_api_token: ""
|
||||
disable_admin_user: false
|
||||
editable_keys: true
|
||||
create_default_peer: false
|
||||
create_default_peer_on_creation: false
|
||||
@@ -23,6 +25,9 @@ core:
|
||||
self_provisioning_allowed: false
|
||||
import_existing: true
|
||||
restore_state: true
|
||||
|
||||
backend:
|
||||
default: local
|
||||
|
||||
advanced:
|
||||
log_level: info
|
||||
@@ -35,13 +40,16 @@ advanced:
|
||||
config_storage_path: ""
|
||||
expiry_check_interval: 15m
|
||||
rule_prio_offset: 20000
|
||||
route_table_offset: 20000
|
||||
api_admin_only: true
|
||||
limit_additional_user_peers: 0
|
||||
|
||||
database:
|
||||
debug: false
|
||||
slow_query_threshold: 0
|
||||
slow_query_threshold: "0"
|
||||
type: sqlite
|
||||
dsn: data/sqlite.db
|
||||
encryption_passphrase: ""
|
||||
|
||||
statistics:
|
||||
use_ping_checks: true
|
||||
@@ -69,6 +77,10 @@ auth:
|
||||
oidc: []
|
||||
oauth: []
|
||||
ldap: []
|
||||
webauthn:
|
||||
enabled: true
|
||||
min_password_length: 16
|
||||
hide_login_form: false
|
||||
|
||||
web:
|
||||
listening_address: :8888
|
||||
@@ -79,6 +91,7 @@ web:
|
||||
session_secret: very_secret
|
||||
csrf_secret: extremely_secret
|
||||
request_logging: false
|
||||
expose_host_info: false
|
||||
cert_file: ""
|
||||
key_File: ""
|
||||
|
||||
@@ -93,6 +106,7 @@ webhook:
|
||||
|
||||
Below you will find sections like
|
||||
[`core`](#core),
|
||||
[`backend`](#backend),
|
||||
[`advanced`](#advanced),
|
||||
[`database`](#database),
|
||||
[`statistics`](#statistics),
|
||||
@@ -114,8 +128,13 @@ More advanced options are found in the subsequent `Advanced` section.
|
||||
- **Description:** The administrator user. This user will be created as a default admin if it does not yet exist.
|
||||
|
||||
### `admin_password`
|
||||
- **Default:** `wgportal`
|
||||
- **Description:** The administrator password. The default password of `wgportal` should be changed immediately.
|
||||
- **Default:** `wgportal-default`
|
||||
- **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`
|
||||
- **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)*
|
||||
@@ -155,6 +174,75 @@ More advanced options are found in the subsequent `Advanced` section.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
### `ignored_local_interfaces`
|
||||
- **Default:** *(empty)*
|
||||
- **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.
|
||||
@@ -207,6 +295,10 @@ Additional or more specialized configuration options for logging and interface c
|
||||
- **Default:** `true`
|
||||
- **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`
|
||||
- **Description:** Limit additional peers a normal user can create. `0` means unlimited.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
@@ -214,13 +306,15 @@ Additional or more specialized configuration options for logging and interface c
|
||||
Configuration for the underlying database used by WireGuard Portal.
|
||||
Supported databases include SQLite, MySQL, Microsoft SQL Server, and Postgres.
|
||||
|
||||
If sensitive values (like private keys) should be stored in an encrypted format, set the `encryption_passphrase` option.
|
||||
|
||||
### `debug`
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, logs all database statements (verbose).
|
||||
|
||||
### `slow_query_threshold`
|
||||
- **Default:** 0
|
||||
- **Description:** A time threshold (e.g., `100ms`) above which queries are considered slow and logged as warnings. If empty or zero, slow query logging is disabled. Format uses `s`, `ms` for seconds, milliseconds, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
|
||||
- **Default:** "0"
|
||||
- **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`
|
||||
@@ -234,6 +328,12 @@ Supported databases include SQLite, MySQL, Microsoft SQL Server, and Postgres.
|
||||
user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
|
||||
```
|
||||
|
||||
### `encryption_passphrase`
|
||||
- **Default:** *(empty)*
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Statistics
|
||||
@@ -274,7 +374,7 @@ Controls how WireGuard Portal collects and reports usage statistics, including p
|
||||
|
||||
### `listening_address`
|
||||
- **Default:** `:8787`
|
||||
- **Description:** Address and port for the integrated Prometheus metric server (e.g., `:8787`).
|
||||
- **Description:** Address and port for the integrated Prometheus metric server (e.g., `:8787` or `127.0.0.1:8787`).
|
||||
|
||||
---
|
||||
|
||||
@@ -322,9 +422,23 @@ Options for configuring email notifications or sending peer configurations via e
|
||||
|
||||
## Auth
|
||||
|
||||
WireGuard Portal supports multiple authentication strategies, including **OpenID Connect** (`oidc`), **OAuth** (`oauth`), and **LDAP** (`ldap`).
|
||||
WireGuard Portal supports multiple authentication strategies, including **OpenID Connect** (`oidc`), **OAuth** (`oauth`), **Passkeys** (`webauthn`) and **LDAP** (`ldap`).
|
||||
Each can have multiple providers configured. Below are the relevant keys.
|
||||
|
||||
Some core authentication options are shared across all providers, while others are specific to each provider type.
|
||||
|
||||
### `min_password_length`
|
||||
- **Default:** `16`
|
||||
- **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`
|
||||
- **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).
|
||||
|
||||
---
|
||||
|
||||
### OIDC
|
||||
@@ -356,6 +470,10 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** A list of additional OIDC scopes (e.g., `profile`, `email`).
|
||||
|
||||
#### `allowed_domains`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups.
|
||||
|
||||
#### `field_map`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** Maps OIDC claims to WireGuard Portal user fields.
|
||||
@@ -425,6 +543,10 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** A list of OAuth scopes.
|
||||
|
||||
#### `allowed_domains`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups.
|
||||
|
||||
#### `field_map`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** Maps OAuth attributes to WireGuard Portal fields.
|
||||
@@ -520,6 +642,8 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
|
||||
```text
|
||||
(&(objectClass=organizationalPerson)(mail={{login_identifier}})(!userAccountControl:1.2.840.113556.1.4.803:=2))
|
||||
```
|
||||
- **Important**: The `login_filter` must always be a valid LDAP filter. It should at most return one user.
|
||||
If the filter returns multiple or no users, the login will fail.
|
||||
|
||||
#### `admin_group`
|
||||
- **Default:** *(empty)*
|
||||
@@ -560,6 +684,16 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
|
||||
|
||||
---
|
||||
|
||||
### WebAuthn (Passkeys)
|
||||
|
||||
The `webauthn` section contains configuration options for WebAuthn authentication (passkeys).
|
||||
|
||||
#### `enabled`
|
||||
- **Default:** `true`
|
||||
- **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.
|
||||
|
||||
## Web
|
||||
|
||||
The web section contains configuration options for the web server, including the listening address, session management, and CSRF protection.
|
||||
@@ -568,7 +702,8 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
|
||||
|
||||
### `listening_address`
|
||||
- **Default:** `:8888`
|
||||
- **Description:** The listening port of the web server.
|
||||
- **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`
|
||||
@@ -599,6 +734,10 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
|
||||
- **Default:** `false`
|
||||
- **Description:** Log all HTTP requests.
|
||||
|
||||
### `expose_host_info`
|
||||
- **Default:** `false`
|
||||
- **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)*
|
||||
- **Description:** (Optional) Path to the TLS certificate file.
|
||||
@@ -612,18 +751,7 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
|
||||
## Webhook
|
||||
|
||||
The webhook section allows you to configure a webhook that is called on certain events in WireGuard Portal.
|
||||
A JSON object is sent in a POST request to the webhook URL with the following structure:
|
||||
```json
|
||||
{
|
||||
"event": "peer_created",
|
||||
"entity": "peer",
|
||||
"identifier": "the-peer-identifier",
|
||||
"payload": {
|
||||
// The payload of the event, e.g. peer data.
|
||||
// Check the API documentation for the exact structure.
|
||||
}
|
||||
}
|
||||
```
|
||||
Further details can be found in the [usage documentation](../usage/webhooks.md).
|
||||
|
||||
### `url`
|
||||
- **Default:** *(empty)*
|
||||
|
@@ -3,23 +3,31 @@ These binary versions can be manually downloaded and installed.
|
||||
|
||||
## Download
|
||||
|
||||
Make sure that you download the correct binary for your architecture. The available binaries are:
|
||||
|
||||
- `wg-portal_linux_amd64` - Linux x86_64
|
||||
- `wg-portal_linux_arm64` - Linux ARM 64-bit
|
||||
- `wg-portal_linux_arm_v7` - Linux ARM 32-bit
|
||||
|
||||
With `curl`:
|
||||
|
||||
```shell
|
||||
curl -L -o wg-portal https://github.com/h44z/wg-portal/releases/download/${WG_PORTAL_VERSION}/wg-portal_linux_amd64
|
||||
```
|
||||
```shell
|
||||
curl -L -o wg-portal https://github.com/h44z/wg-portal/releases/download/${WG_PORTAL_VERSION}/wg-portal_linux_amd64
|
||||
```
|
||||
|
||||
With `wget`:
|
||||
|
||||
```shell
|
||||
wget -O wg-portal https://github.com/h44z/wg-portal/releases/download/${WG_PORTAL_VERSION}/wg-portal_linux_amd64
|
||||
```
|
||||
```shell
|
||||
wget -O wg-portal https://github.com/h44z/wg-portal/releases/download/${WG_PORTAL_VERSION}/wg-portal_linux_amd64
|
||||
```
|
||||
|
||||
with `gh cli`:
|
||||
|
||||
```shell
|
||||
gh release download ${WG_PORTAL_VERSION} --repo h44z/wg-portal --output wg-portal --pattern '*amd64'
|
||||
```
|
||||
```shell
|
||||
gh release download ${WG_PORTAL_VERSION} --repo h44z/wg-portal --output wg-portal --pattern '*amd64'
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Install
|
||||
|
||||
@@ -28,7 +36,7 @@ sudo mkdir -p /opt/wg-portal
|
||||
sudo install wg-portal /opt/wg-portal/
|
||||
```
|
||||
|
||||
## Unreleased
|
||||
## 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).
|
||||
|
||||
Unreleased versions could be downloaded from
|
||||
[GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster) artifacts also.
|
||||
|
@@ -10,10 +10,10 @@ The recommended method for deploying WireGuard Portal is via Docker Compose for
|
||||
A sample docker-compose.yml (managing WireGuard interfaces directly on the host) is provided below:
|
||||
|
||||
```yaml
|
||||
--8<-- "docker-compose.yml::17"
|
||||
--8<-- "docker-compose.yml::19"
|
||||
```
|
||||
|
||||
By default, the webserver is listening on port **8888**.
|
||||
By default, the webserver for the UI is listening on port **8888** on all available interfaces.
|
||||
|
||||
Volumes for `/app/data` and `/app/config` should be used ensure data persistence across container restarts.
|
||||
|
||||
@@ -32,6 +32,8 @@ WireGuard Portal supports managing WireGuard interfaces through three distinct d
|
||||
network_mode: "host"
|
||||
...
|
||||
```
|
||||
> :warning: If host networking is used, the WireGuard Portal UI will be accessible on all the host's IP addresses if the listening address is set to `:8888` in the configuration file.
|
||||
To avoid this, you can bind the listening address to a specific IP address, for example, the loopback address (`127.0.0.1:8888`). It is also possible to deploy firewall rules to restrict access to the WireGuard Portal UI.
|
||||
|
||||
- **Within the WireGuard Portal Docker container**:
|
||||
WireGuard interfaces can be managed directly from within the WireGuard Portal container itself.
|
||||
@@ -39,12 +41,13 @@ WireGuard Portal supports managing WireGuard interfaces through three distinct d
|
||||
```yaml
|
||||
services:
|
||||
wg-portal:
|
||||
image: wgportal/wg-portal:latest
|
||||
image: wgportal/wg-portal:v2
|
||||
container_name: wg-portal
|
||||
...
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
ports:
|
||||
# host port : container port
|
||||
# WireGuard port, needs to match the port in wg-portal interface config (add one port mapping for each interface)
|
||||
- "51820:51820/udp"
|
||||
# Web UI port
|
||||
@@ -52,6 +55,7 @@ WireGuard Portal supports managing WireGuard interfaces through three distinct d
|
||||
sysctls:
|
||||
- net.ipv4.conf.all.src_valid_mark=1
|
||||
volumes:
|
||||
# host path : container path
|
||||
- ./wg/data:/app/data
|
||||
- ./wg/config:/app/config
|
||||
```
|
||||
@@ -63,13 +67,14 @@ WireGuard Portal supports managing WireGuard interfaces through three distinct d
|
||||
```yaml
|
||||
services:
|
||||
wg-portal:
|
||||
image: wgportal/wg-portal:latest
|
||||
image: wgportal/wg-portal:v2
|
||||
container_name: wg-portal
|
||||
...
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
network_mode: "service:wireguard" # So we ensure to stay on the same network as the wireguard container.
|
||||
volumes:
|
||||
# host path : container path
|
||||
- ./wg/etc:/etc/wireguard
|
||||
- ./wg/data:/app/data
|
||||
- ./wg/config:/app/config
|
||||
@@ -81,6 +86,7 @@ WireGuard Portal supports managing WireGuard interfaces through three distinct d
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
ports:
|
||||
# host port : container port
|
||||
- "51820:51820/udp" # WireGuard port, needs to match the port in wg-portal interface config
|
||||
- "8888:8888/tcp" # Noticed that the port of the web UI is exposed in the wireguard container.
|
||||
volumes:
|
||||
@@ -104,36 +110,38 @@ WireGuard Portal supports managing WireGuard interfaces through three distinct d
|
||||
## Image Versioning
|
||||
|
||||
All images are hosted on Docker Hub at [https://hub.docker.com/r/wgportal/wg-portal](https://hub.docker.com/r/wgportal/wg-portal) or in the [GitHub Container Registry](https://github.com/h44z/wg-portal/pkgs/container/wg-portal).
|
||||
|
||||
Version **2** is the current stable release. Version **1** has moved to legacy status and is no longer recommended.
|
||||
|
||||
There are three types of tags in the repository:
|
||||
|
||||
#### Semantic versioned tags
|
||||
|
||||
For example, `2.0.0-rc.1` or `v2.0.0-rc.1`.
|
||||
|
||||
These are official releases of WireGuard Portal. They correspond to the GitHub tags that we make, and you can see the release notes for them here: [https://github.com/h44z/wg-portal/releases](https://github.com/h44z/wg-portal/releases).
|
||||
These are official releases of WireGuard Portal. For production deployments of WireGuard Portal, we strongly recommend using one of these versioned tags instead of the latest or canary tags.
|
||||
|
||||
Once these tags show up in this repository, they will never change.
|
||||
There are different types of these tags:
|
||||
|
||||
For production deployments of WireGuard Portal, we strongly recommend using one of these tags, e.g. **wgportal/wg-portal:1.0.19**, instead of the latest or canary tags.
|
||||
- Major version tags: `v2` or `2`. These tags always refer to the latest image for WireGuard Portal version **2**.
|
||||
- Minor version tags: `v2.x` or `2.0`. These tags always refer to the latest image for WireGuard Portal version **2.x**.
|
||||
- Specific version tags (patch version): `v2.0.0` or `2.0.0`. These tags denote a very specific release. They correspond to the GitHub tags that we make, and you can see the release notes for them here: [https://github.com/h44z/wg-portal/releases](https://github.com/h44z/wg-portal/releases). Once these tags for a specific version show up in the Docker repository, they will never change.
|
||||
|
||||
If you only want to stay at the same major or major+minor version, use either `v[MAJOR]` or `[MAJOR].[MINOR]` tags. For example `v1` or `1.0`.
|
||||
#### The `latest` tag
|
||||
|
||||
Version **1** is currently **stable**, version **2** is in **development**.
|
||||
The lastest tag is the latest stable release of WireGuard Portal. For version **2**, this is the same as the `v2` tag.
|
||||
|
||||
#### latest
|
||||
#### The `master` tag
|
||||
|
||||
This is the most recent build to master! It changes a lot and is very unstable.
|
||||
This is the most recent build to the main branch! It changes a lot and is very unstable.
|
||||
|
||||
We recommend that you don't use it except for development purposes.
|
||||
We recommend that you don't use it except for development purposes or to test the latest features.
|
||||
|
||||
#### Branch tags
|
||||
|
||||
For each commit in the master and the stable branch, a corresponding Docker image is build. These images use the `master` or `stable` tags.
|
||||
|
||||
## Configuration
|
||||
|
||||
You can configure WireGuard Portal using a YAML configuration file.
|
||||
The filepath of the YAML configuration file defaults to `/app/config/config.yml`.
|
||||
The filepath of the YAML configuration file defaults to `/app/config/config.yaml`.
|
||||
It is possible to override the configuration filepath using the environment variable **WG_PORTAL_CONFIG**.
|
||||
|
||||
By default, WireGuard Portal uses an SQLite database. The database is stored in `/app/data/sqlite.db`.
|
||||
@@ -145,7 +153,7 @@ You should mount those directories as a volume:
|
||||
|
||||
A detailed description of the configuration options can be found [here](../configuration/overview.md).
|
||||
|
||||
If you want to access configuration files in wg-quick format, you can mount the `/etc/wireguard` directory to a location of your choice.
|
||||
If you want to access configuration files in wg-quick format, you can mount the `/etc/wireguard` directory inside the container to a location of your choice.
|
||||
Also enable the `config_storage_path` option in the configuration file:
|
||||
```yaml
|
||||
advanced:
|
||||
|
98
docs/documentation/getting-started/reverse-proxy.md
Normal file
98
docs/documentation/getting-started/reverse-proxy.md
Normal file
@@ -0,0 +1,98 @@
|
||||
## Reverse Proxy for HTTPS
|
||||
|
||||
For production deployments, always serve the WireGuard Portal over HTTPS. You have two options to secure your connection:
|
||||
|
||||
|
||||
### Reverse Proxy
|
||||
|
||||
Let a front‐end proxy handle HTTPS for you. This also frees you from managing certificates manually and is therefore the preferred option.
|
||||
You can use Nginx, Traefik, Caddy or any other proxy.
|
||||
|
||||
Below is an example using a Docker Compose stack with [Traefik](https://traefik.io/traefik/).
|
||||
It exposes the WireGuard Portal on `https://wg.domain.com` and redirects initial HTTP traffic to HTTPS.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
reverse-proxy:
|
||||
image: traefik:v3.3
|
||||
restart: unless-stopped
|
||||
command:
|
||||
#- '--log.level=DEBUG'
|
||||
- '--providers.docker.endpoint=unix:///var/run/docker.sock'
|
||||
- '--providers.docker.exposedbydefault=false'
|
||||
- '--entrypoints.web.address=:80'
|
||||
- '--entrypoints.websecure.address=:443'
|
||||
- '--entrypoints.websecure.http3'
|
||||
- '--certificatesresolvers.letsencryptresolver.acme.httpchallenge=true'
|
||||
- '--certificatesresolvers.letsencryptresolver.acme.httpchallenge.entrypoint=web'
|
||||
- '--certificatesresolvers.letsencryptresolver.acme.email=your.email@domain.com'
|
||||
- '--certificatesresolvers.letsencryptresolver.acme.storage=/letsencrypt/acme.json'
|
||||
#- '--certificatesresolvers.letsencryptresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory' # just for testing
|
||||
ports:
|
||||
- 80:80 # for HTTP
|
||||
- 443:443/tcp # for HTTPS
|
||||
- 443:443/udp # for HTTP/3
|
||||
volumes:
|
||||
- acme-certs:/letsencrypt
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
# HTTP Catchall for redirecting HTTP -> HTTPS
|
||||
- 'traefik.http.routers.dashboard-catchall.rule=Host(`wg.domain.com`) && PathPrefix(`/`)'
|
||||
- 'traefik.http.routers.dashboard-catchall.entrypoints=web'
|
||||
- 'traefik.http.routers.dashboard-catchall.middlewares=redirect-to-https'
|
||||
- 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https'
|
||||
|
||||
wg-portal:
|
||||
image: wgportal/wg-portal:v2
|
||||
container_name: wg-portal
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
ports:
|
||||
# host port : container port
|
||||
# WireGuard port, needs to match the port in wg-portal interface config (add one port mapping for each interface)
|
||||
- "51820:51820/udp"
|
||||
# Web UI port (only available on localhost, Traefik will handle the HTTPS)
|
||||
- "127.0.0.1:8888:8888/tcp"
|
||||
sysctls:
|
||||
- net.ipv4.conf.all.src_valid_mark=1
|
||||
volumes:
|
||||
# host path : container path
|
||||
- ./wg/data:/app/data
|
||||
- ./wg/config:/app/config
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.wgportal.rule=Host(`wg.domain.com`)'
|
||||
- 'traefik.http.routers.wgportal.entrypoints=websecure'
|
||||
- 'traefik.http.routers.wgportal.tls.certresolver=letsencryptresolver'
|
||||
- 'traefik.http.routers.wgportal.service=wgportal'
|
||||
- 'traefik.http.services.wgportal.loadbalancer.server.port=8888'
|
||||
|
||||
volumes:
|
||||
acme-certs:
|
||||
```
|
||||
|
||||
The WireGuard Portal configuration must be updated accordingly so that the correct external URL is set for the web interface:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
external_url: https://wg.domain.com
|
||||
```
|
||||
|
||||
### Built-in TLS
|
||||
|
||||
If you prefer to let WireGuard Portal handle TLS itself, you can use the built-in TLS support.
|
||||
In your `config.yaml`, under the `web` section, point to your certificate and key files:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
cert_file: /path/to/your/fullchain.pem
|
||||
key_file: /path/to/your/privkey.pem
|
||||
```
|
||||
|
||||
The web server will then use these files to serve HTTPS traffic directly instead of HTTP.
|
@@ -22,4 +22,5 @@ make build
|
||||
## Install
|
||||
|
||||
Compiled binary will be available in `./dist` directory.
|
||||
|
||||
For installation instructions, check the [Binaries](./binaries.md) section.
|
||||
|
@@ -13,7 +13,7 @@ By default, WG-Portal exposes Prometheus metrics on port `8787` if interface/pee
|
||||
|
||||
## Prometheus Config
|
||||
|
||||
Add following scrape job to your Prometheus config file:
|
||||
Add the following scrape job to your Prometheus config file:
|
||||
|
||||
```yaml
|
||||
# prometheus.yaml
|
||||
|
@@ -1 +1 @@
|
||||
--8<-- "README.md:20:47"
|
||||
--8<-- "README.md:12:41"
|
||||
|
@@ -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
|
||||
@@ -692,6 +698,7 @@ paths:
|
||||
tags:
|
||||
- Interfaces
|
||||
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, ...).
|
||||
operationId: interfaces_handleUpdatePut
|
||||
parameters:
|
||||
- description: The interface identifier.
|
||||
@@ -739,6 +746,7 @@ paths:
|
||||
- Interfaces
|
||||
/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, ...).
|
||||
operationId: interfaces_handleCreatePost
|
||||
parameters:
|
||||
- description: The interface data.
|
||||
@@ -779,6 +787,34 @@ paths:
|
||||
summary: Create a new interface record.
|
||||
tags:
|
||||
- Interfaces
|
||||
/interface/prepare:
|
||||
get:
|
||||
description: This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).
|
||||
operationId: interfaces_handlePrepareGet
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.Interface'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
security:
|
||||
- BasicAuth: []
|
||||
summary: Prepare a new interface record.
|
||||
tags:
|
||||
- Interfaces
|
||||
/metrics/by-interface/{id}:
|
||||
get:
|
||||
operationId: metrics_handleMetricsForInterfaceGet
|
||||
@@ -967,7 +1003,7 @@ paths:
|
||||
tags:
|
||||
- Peers
|
||||
put:
|
||||
description: Only admins can update existing records.
|
||||
description: Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).
|
||||
operationId: peers_handleUpdatePut
|
||||
parameters:
|
||||
- description: The peer identifier.
|
||||
@@ -1078,7 +1114,7 @@ paths:
|
||||
- Peers
|
||||
/peer/new:
|
||||
post:
|
||||
description: Only admins can create new records.
|
||||
description: Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).
|
||||
operationId: peers_handleCreatePost
|
||||
parameters:
|
||||
- description: The peer data.
|
||||
@@ -1119,6 +1155,48 @@ paths:
|
||||
summary: Create a new peer record.
|
||||
tags:
|
||||
- Peers
|
||||
/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.
|
||||
operationId: peers_handlePrepareGet
|
||||
parameters:
|
||||
- description: The interface identifier.
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.Peer'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
security:
|
||||
- BasicAuth: []
|
||||
summary: Prepare a new peer record for the given WireGuard interface.
|
||||
tags:
|
||||
- Peers
|
||||
/provisioning/data/peer-config:
|
||||
get:
|
||||
description: Normal users can only access their own record. Admins can access all records.
|
||||
|
@@ -1,12 +1,11 @@
|
||||
For production deployments of WireGuard Portal, we strongly recommend using version 1.
|
||||
If you want to use version 2, please be aware that it is still a release candidate and not yet fully stable.
|
||||
Major upgrades between different versions may require special procedures, which are described in the following sections.
|
||||
|
||||
## Upgrade from v1 to v2
|
||||
|
||||
> :warning: Before upgrading from V1, make sure that you have a backup of your currently working configuration files and database!
|
||||
|
||||
To start the upgrade process, start the wg-portal binary with the **-migrateFrom** parameter.
|
||||
The configuration (config.yml) for WireGuard Portal must be updated and valid before starting the upgrade.
|
||||
The configuration (config.yaml) for WireGuard Portal must be updated and valid before starting the upgrade.
|
||||
|
||||
To upgrade from a previous SQLite database, start wg-portal like:
|
||||
|
||||
@@ -14,14 +13,16 @@ To upgrade from a previous SQLite database, start wg-portal like:
|
||||
./wg-portal-amd64 -migrateFrom=old_wg_portal.db
|
||||
```
|
||||
|
||||
You can also specify the database type using the parameter **-migrateFromType**, supported types: mysql, mssql, postgres or sqlite.
|
||||
You can also specify the database type using the parameter **-migrateFromType**.
|
||||
Supported database types: `mysql`, `mssql`, `postgres` or `sqlite`.
|
||||
|
||||
For example:
|
||||
|
||||
```shell
|
||||
./wg-portal-amd64 -migrateFromType=mysql -migrateFrom='user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local'
|
||||
```
|
||||
|
||||
The upgrade will transform the old, existing database and store the values in the new database specified in the **config.yml** configuration file.
|
||||
The upgrade will transform the old, existing database and store the values in the new database specified in the **config.yaml** configuration file.
|
||||
Ensure that the new database does not contain any data!
|
||||
|
||||
If you are using Docker, you can adapt the docker-compose.yml file to start the upgrade process:
|
||||
@@ -29,8 +30,8 @@ If you are using Docker, you can adapt the docker-compose.yml file to start the
|
||||
```yaml
|
||||
services:
|
||||
wg-portal:
|
||||
image: wgportal/wg-portal:latest
|
||||
image: wgportal/wg-portal:v2
|
||||
# ... other settings
|
||||
restart: no
|
||||
command: ["-migrateFrom=/app/data/wg_portal.db"]
|
||||
command: ["-migrateFrom=/app/data/old_wg_portal.db"]
|
||||
```
|
||||
|
57
docs/documentation/usage/backends.md
Normal file
57
docs/documentation/usage/backends.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 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+.
|
||||
|
||||
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)
|
57
docs/documentation/usage/general.md
Normal file
57
docs/documentation/usage/general.md
Normal file
@@ -0,0 +1,57 @@
|
||||
This documentation section describes the general usage of WireGuard Portal.
|
||||
If you are looking for specific setup instructions, please refer to the *Getting Started* and [*Configuration*](../configuration/overview.md) sections,
|
||||
for example, using a [Docker](../getting-started/docker.md) deployment.
|
||||
|
||||
## Basic Concepts
|
||||
|
||||
WireGuard Portal is a web-based configuration portal for WireGuard server management. It allows managing multiple WireGuard interfaces and users from a single web UI.
|
||||
WireGuard Interfaces can be categorized into three types:
|
||||
|
||||
- **Server**: A WireGuard server interface that to which multiple peers can connect. In this mode, it is possible to specify default settings for all peers, such as the IP address range, DNS servers, and MTU size.
|
||||
- **Client**: A WireGuard client interface that can be used to connect to a WireGuard server. Usually, such an interface has exactly one peer.
|
||||
- **Unknown**: This is the default type for imported interfaces. It is encouraged to change the type to either `Server` or `Client` after importing the interface.
|
||||
|
||||
## 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.
|
||||
|
||||
So the default URL to access the web UI is:
|
||||
|
||||
```
|
||||
http://localhost:8888
|
||||
```
|
||||
|
||||
A freshly set-up WireGuard Portal instance will have a default admin user with the username `admin@wgportal.local` and the password `wgportal-default`.
|
||||
You can and should override the default credentials in the configuration file. Make sure to change the default password immediately after the first login!
|
||||
|
||||
|
||||
### Basic UI Description
|
||||
|
||||

|
||||
|
||||
As seen in the screenshot above, the web UI is divided into several sections which are accessible via the navigation bar on the top of the screen.
|
||||
|
||||
1. **Home**: The landing page of WireGuard Portal. It provides a staring point for the user to access the different sections of the web UI. It also provides quick links to WireGuard Client downloads or official documentation.
|
||||
2. **Interfaces**: This section allows you to manage the WireGuard interfaces. You can add, edit, or delete interfaces, as well as view their status and statistics. Peers for each interface can be managed here as well.
|
||||
3. **Users**: This section allows you to manage the users of WireGuard Portal. You can add, edit, or delete users, as well as view their status and statistics.
|
||||
4. **Key Generator**: This section allows you to generate WireGuard keys locally on your browser. The generated keys are never sent to the server. This is useful if you want to generate keys for a new peer without having to store the private keys in the database.
|
||||
5. **Profile / Settings**: This section allows you to access your own profile page, settings, and audit logs.
|
||||
|
||||
|
||||
### Interface View
|
||||
|
||||

|
||||
|
||||
The interface view provides an overview of the WireGuard interfaces and peers configured in WireGuard Portal.
|
||||
|
||||
The most important elements are:
|
||||
|
||||
1. **Interface Selector**: This dropdown allows you to select the WireGuard interface you want to manage.
|
||||
All further actions will be performed on the selected interface.
|
||||
2. **Create new Interface**: This button allows you to create a new WireGuard interface.
|
||||
3. **Interface Overview**: This section provides an overview of the selected WireGuard interface. It shows the interface type, number of peers, and other important information.
|
||||
4. **List of Peers**: This section provides a list of all peers associated with the selected WireGuard interface. You can view, add, edit, or delete peers from this list.
|
||||
5. **Add new Peer**: This button allows you to add a new peer to the selected WireGuard interface.
|
||||
6. **Add multiple Peers**: This button allows you to add multiple peers to the selected WireGuard interface.
|
||||
This is useful if you want to add a large number of peers at once.
|
37
docs/documentation/usage/ldap.md
Normal file
37
docs/documentation/usage/ldap.md
Normal file
@@ -0,0 +1,37 @@
|
||||
WireGuard Portal lets you hook up any LDAP server such as Active Directory or OpenLDAP for both authentication and user sync.
|
||||
You can even register multiple LDAP servers side-by-side. When someone logs in via LDAP, their specific provider is remembered,
|
||||
so there's no risk of cross-provider conflicts. Details on the log-in process can be found in the [Security](security.md#ldap-authentication) documentation.
|
||||
|
||||
If you enable LDAP synchronization, all users within the LDAP directory will be created automatically in the WireGuard Portal database if they do not exist.
|
||||
If a user is disabled or deleted in LDAP, the user will be disabled in WireGuard Portal as well.
|
||||
The synchronization process can be fine-tuned by multiple parameters, which are described below.
|
||||
|
||||
## LDAP Synchronization
|
||||
|
||||
WireGuard Portal can automatically synchronize users from LDAP to the database.
|
||||
To enable this feature, set the `sync_interval` property in the LDAP provider configuration to a value greater than "0".
|
||||
The value is a string representing a duration, such as "15m" for 15 minutes or "1h" for 1 hour (check the [exact format definition](https://pkg.go.dev/time#ParseDuration) for details).
|
||||
The synchronization process will run in the background and synchronize users from LDAP to the database at the specified interval.
|
||||
Also make sure that the `sync_filter` property is a well-formed LDAP filter, or synchronization will fail.
|
||||
|
||||
### Limiting Synchronization to Specific Users
|
||||
|
||||
Use the `sync_filter` property in your LDAP provider block to restrict which users get synchronized.
|
||||
It accepts any valid LDAP search filter, only entries matching that filter will be pulled into the portal's database.
|
||||
|
||||
For example, to import only users with a `mail` attribute:
|
||||
```yaml
|
||||
auth:
|
||||
ldap:
|
||||
- id: ldap
|
||||
# ... other settings
|
||||
sync_filter: (mail=*)
|
||||
```
|
||||
|
||||
### Disable Missing Users
|
||||
|
||||
If you set the `disable_missing` property to `true`, any user that is not found in LDAP during synchronization will be disabled in WireGuard Portal.
|
||||
All peers associated with that user will also be disabled.
|
||||
|
||||
If you want a user and its peers to be automatically re-enabled once they are found in LDAP again, set the `auto_re_enable` property to `true`.
|
||||
This will only re-enable the user if they where disabled by the synchronization process. Manually disabled users will not be re-enabled.
|
160
docs/documentation/usage/security.md
Normal file
160
docs/documentation/usage/security.md
Normal file
@@ -0,0 +1,160 @@
|
||||
This section describes the security features available to administrators for hardening WireGuard Portal and protecting its data.
|
||||
|
||||
## Authentication
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## UI and API Access
|
||||
|
||||
WireGuard Portal provides a web UI and a REST API for user interaction. It is important to secure these interfaces to prevent unauthorized access and data breaches.
|
||||
|
||||
### HTTPS
|
||||
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.
|
285
docs/documentation/usage/webhooks.md
Normal file
285
docs/documentation/usage/webhooks.md
Normal file
@@ -0,0 +1,285 @@
|
||||
|
||||
Webhooks allow WireGuard Portal to notify external services about events such as user creation, device changes, or configuration updates. This enables integration with other systems and automation workflows.
|
||||
|
||||
When webhooks are configured and a specified event occurs, WireGuard Portal sends an HTTP **POST** request to the configured webhook URL.
|
||||
The payload contains event-specific data in JSON format.
|
||||
|
||||
## Configuration
|
||||
|
||||
All available configuration options for webhooks can be found in the [configuration overview](../configuration/overview.md#webhook).
|
||||
|
||||
A basic webhook configuration looks like this:
|
||||
|
||||
```yaml
|
||||
webhook:
|
||||
url: https://your-service.example.com/webhook
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
Webhooks can be secured by using a shared secret. This secret is included in the `Authorization` header of the webhook request, allowing your service to verify the authenticity of the request.
|
||||
You can set the shared secret in the webhook configuration:
|
||||
|
||||
```yaml
|
||||
webhook:
|
||||
url: https://your-service.example.com/webhook
|
||||
secret: "Basic dXNlcm5hbWU6cGFzc3dvcmQ="
|
||||
```
|
||||
|
||||
You should also make sure that your webhook endpoint is secured with HTTPS to prevent eavesdropping and tampering.
|
||||
|
||||
## Available Events
|
||||
|
||||
WireGuard Portal supports various events that can trigger webhooks. The following events are available:
|
||||
|
||||
- `create`: Triggered when a new entity is created.
|
||||
- `update`: Triggered when an existing entity is updated.
|
||||
- `delete`: Triggered when an entity is deleted.
|
||||
- `connect`: Triggered when a user connects to the VPN.
|
||||
- `disconnect`: Triggered when a user disconnects from the VPN.
|
||||
|
||||
The following entity models are supported for webhook events:
|
||||
|
||||
- `user`: WireGuard Portal users support creation, update, or deletion events.
|
||||
- `peer`: Peers support creation, update, or deletion events. Via the `peer_metric` entity, you can also receive connection status updates.
|
||||
- `peer_metric`: Peer metrics support connection status updates, such as when a peer connects or disconnects.
|
||||
- `interface`: WireGuard interfaces support creation, update, or deletion events.
|
||||
|
||||
## Payload Structure
|
||||
|
||||
All webhook events send a JSON payload containing relevant data. The structure of the payload depends on the event type and entity involved.
|
||||
A common shell structure for webhook payloads is as follows:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "create", // The event type, e.g. "create", "update", "delete", "connect", "disconnect"
|
||||
"entity": "user", // The entity type, e.g. "user", "peer", "peer_metric", "interface"
|
||||
"identifier": "the-user-identifier", // Unique identifier of the entity, e.g. user ID or peer ID
|
||||
"payload": {
|
||||
// The payload of the event, e.g. a Peer model.
|
||||
// Detailed model descriptions are provided below.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Payload Models
|
||||
|
||||
All payload models are encoded as JSON objects. Fields with empty values might be omitted in the payload.
|
||||
|
||||
#### 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 |
|
||||
|
||||
|
||||
#### Peer Payload (entity: `peer`)
|
||||
|
||||
| JSON Field | Type | Description |
|
||||
|----------------------|------------|----------------------------------------|
|
||||
| CreatedBy | string | Creator identifier |
|
||||
| UpdatedBy | string | Last updater identifier |
|
||||
| CreatedAt | time.Time | Creation timestamp |
|
||||
| UpdatedAt | time.Time | Last update timestamp |
|
||||
| Endpoint | string | Peer endpoint address |
|
||||
| EndpointPublicKey | string | Public key of peer endpoint |
|
||||
| AllowedIPsStr | string | Allowed IPs |
|
||||
| ExtraAllowedIPsStr | string | Extra allowed IPs |
|
||||
| PresharedKey | string | Pre-shared key for encryption |
|
||||
| PersistentKeepalive | int | Keepalive interval in seconds |
|
||||
| DisplayName | string | Display name of the peer |
|
||||
| Identifier | string | Unique identifier |
|
||||
| UserIdentifier | string | Associated user ID (optional) |
|
||||
| InterfaceIdentifier | string | Interface this peer is attached to |
|
||||
| Disabled | *time.Time | When the peer was disabled |
|
||||
| DisabledReason | string | Reason for being disabled |
|
||||
| ExpiresAt | *time.Time | Expiration date |
|
||||
| Notes | string | Notes for this peer |
|
||||
| AutomaticallyCreated | bool | Whether peer was auto-generated |
|
||||
| PrivateKey | string | Peer private key |
|
||||
| PublicKey | string | Peer public key |
|
||||
| InterfaceType | string | Type of the peer interface |
|
||||
| Addresses | []string | IP addresses |
|
||||
| CheckAliveAddress | string | Address used for alive checks |
|
||||
| DnsStr | string | DNS servers |
|
||||
| DnsSearchStr | string | DNS search domains |
|
||||
| Mtu | int | MTU (Maximum Transmission Unit) |
|
||||
| FirewallMark | uint32 | Firewall mark (optional) |
|
||||
| RoutingTable | string | Custom routing table (optional) |
|
||||
| PreUp | string | Command before bringing up interface |
|
||||
| PostUp | string | Command after bringing up interface |
|
||||
| PreDown | string | Command before bringing down interface |
|
||||
| PostDown | string | Command after bringing down interface |
|
||||
|
||||
|
||||
#### Interface Payload (entity: `interface`)
|
||||
|
||||
| JSON Field | Type | Description |
|
||||
|----------------------------|------------|----------------------------------------|
|
||||
| CreatedBy | string | Creator identifier |
|
||||
| UpdatedBy | string | Last updater identifier |
|
||||
| CreatedAt | time.Time | Creation timestamp |
|
||||
| UpdatedAt | time.Time | Last update timestamp |
|
||||
| Identifier | string | Unique identifier |
|
||||
| PrivateKey | string | Private key for the interface |
|
||||
| PublicKey | string | Public key for the interface |
|
||||
| ListenPort | int | Listening port |
|
||||
| Addresses | []string | IP addresses |
|
||||
| DnsStr | string | DNS servers |
|
||||
| DnsSearchStr | string | DNS search domains |
|
||||
| Mtu | int | MTU (Maximum Transmission Unit) |
|
||||
| FirewallMark | uint32 | Firewall mark |
|
||||
| RoutingTable | string | Custom routing table |
|
||||
| PreUp | string | Command before bringing up interface |
|
||||
| PostUp | string | Command after bringing up interface |
|
||||
| PreDown | string | Command before bringing down interface |
|
||||
| PostDown | string | Command after bringing down interface |
|
||||
| SaveConfig | bool | Whether to save config to file |
|
||||
| DisplayName | string | Human-readable name |
|
||||
| Type | string | Type of interface |
|
||||
| DriverType | string | Driver used |
|
||||
| Disabled | *time.Time | When the interface was disabled |
|
||||
| DisabledReason | string | Reason for being disabled |
|
||||
| PeerDefNetworkStr | string | Default peer network configuration |
|
||||
| PeerDefDnsStr | string | Default peer DNS servers |
|
||||
| PeerDefDnsSearchStr | string | Default peer DNS search domains |
|
||||
| PeerDefEndpoint | string | Default peer endpoint |
|
||||
| PeerDefAllowedIPsStr | string | Default peer allowed IPs |
|
||||
| PeerDefMtu | int | Default peer MTU |
|
||||
| PeerDefPersistentKeepalive | int | Default keepalive value |
|
||||
| PeerDefFirewallMark | uint32 | Default firewall mark for peers |
|
||||
| PeerDefRoutingTable | string | Default routing table for peers |
|
||||
| PeerDefPreUp | string | Default peer pre-up command |
|
||||
| PeerDefPostUp | string | Default peer post-up command |
|
||||
| PeerDefPreDown | string | Default peer pre-down command |
|
||||
| PeerDefPostDown | string | Default peer post-down command |
|
||||
|
||||
|
||||
#### Peer Metrics Payload (entity: `peer_metric`)
|
||||
|
||||
| JSON Field | Type | Description |
|
||||
|------------|------------|----------------------------|
|
||||
| Status | PeerStatus | Current status of the peer |
|
||||
| Peer | Peer | Peer data |
|
||||
|
||||
`PeerStatus` sub-structure:
|
||||
|
||||
| JSON Field | Type | Description |
|
||||
|------------------|------------|------------------------------|
|
||||
| UpdatedAt | time.Time | Time of last status update |
|
||||
| IsConnected | bool | Is peer currently connected |
|
||||
| IsPingable | bool | Can peer be pinged |
|
||||
| LastPing | *time.Time | Time of last successful ping |
|
||||
| BytesReceived | uint64 | Bytes received from peer |
|
||||
| BytesTransmitted | uint64 | Bytes sent to peer |
|
||||
| Endpoint | string | Last known endpoint |
|
||||
| LastHandshake | *time.Time | Last successful handshake |
|
||||
| LastSessionStart | *time.Time | Time the last session began |
|
||||
|
||||
|
||||
### Example Payloads
|
||||
|
||||
The following payload is an example of a webhook event when a peer connects to the VPN:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "connect",
|
||||
"entity": "peer_metric",
|
||||
"identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
|
||||
"payload": {
|
||||
"Status": {
|
||||
"UpdatedAt": "2025-06-27T22:20:08.734900034+02:00",
|
||||
"IsConnected": true,
|
||||
"IsPingable": false,
|
||||
"BytesReceived": 212,
|
||||
"BytesTransmitted": 2884,
|
||||
"Endpoint": "10.55.66.77:58756",
|
||||
"LastHandshake": "2025-06-27T22:19:46.580842776+02:00",
|
||||
"LastSessionStart": "2025-06-27T22:19:46.580842776+02:00"
|
||||
},
|
||||
"Peer": {
|
||||
"CreatedBy": "admin@wgportal.local",
|
||||
"UpdatedBy": "admin@wgportal.local",
|
||||
"CreatedAt": "2025-06-26T21:43:49.251839574+02:00",
|
||||
"UpdatedAt": "2025-06-27T22:18:39.67763985+02:00",
|
||||
"Endpoint": "10.55.66.1:51820",
|
||||
"EndpointPublicKey": "eiVibpi3C2PUPcx2kwA5s09OgHx7AEaKMd33k0LQ5mM=",
|
||||
"AllowedIPsStr": "10.11.12.0/24,fdfd:d3ad:c0de:1234::/64",
|
||||
"ExtraAllowedIPsStr": "",
|
||||
"PresharedKey": "p9DDeLUSLOdQcjS8ZsBAiqUzwDIUvTyzavRZFuzhvyE=",
|
||||
"PersistentKeepalive": 16,
|
||||
"DisplayName": "Peer Fb5TaziA",
|
||||
"Identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
|
||||
"UserIdentifier": "admin@wgportal.local",
|
||||
"InterfaceIdentifier": "wgTesting",
|
||||
"AutomaticallyCreated": false,
|
||||
"PrivateKey": "QBFNBe+7J49ergH0ze2TGUJMFrL/2bOL50Z2cgluYW8=",
|
||||
"PublicKey": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
|
||||
"InterfaceType": "client",
|
||||
"Addresses": [
|
||||
"10.11.12.10/32",
|
||||
"fdfd:d3ad:c0de:1234::a/128"
|
||||
],
|
||||
"CheckAliveAddress": "",
|
||||
"DnsStr": "",
|
||||
"DnsSearchStr": "",
|
||||
"Mtu": 1420
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Here is another example of a webhook event when a peer is updated:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "update",
|
||||
"entity": "peer",
|
||||
"identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
|
||||
"payload": {
|
||||
"CreatedBy": "admin@wgportal.local",
|
||||
"UpdatedBy": "admin@wgportal.local",
|
||||
"CreatedAt": "2025-06-26T21:43:49.251839574+02:00",
|
||||
"UpdatedAt": "2025-06-27T22:18:39.67763985+02:00",
|
||||
"Endpoint": "10.55.66.1:51820",
|
||||
"EndpointPublicKey": "eiVibpi3C2PUPcx2kwA5s09OgHx7AEaKMd33k0LQ5mM=",
|
||||
"AllowedIPsStr": "10.11.12.0/24,fdfd:d3ad:c0de:1234::/64",
|
||||
"ExtraAllowedIPsStr": "",
|
||||
"PresharedKey": "p9DDeLUSLOdQcjS8ZsBAiqUzwDIUvTyzavRZFuzhvyE=",
|
||||
"PersistentKeepalive": 16,
|
||||
"DisplayName": "Peer Fb5TaziA",
|
||||
"Identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
|
||||
"UserIdentifier": "admin@wgportal.local",
|
||||
"InterfaceIdentifier": "wgTesting",
|
||||
"AutomaticallyCreated": false,
|
||||
"PrivateKey": "QBFNBe+7J49ergH0ze2TGUJMFrL/2bOL50Z2cgluYW8=",
|
||||
"PublicKey": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
|
||||
"InterfaceType": "client",
|
||||
"Addresses": [
|
||||
"10.11.12.10/32",
|
||||
"fdfd:d3ad:c0de:1234::a/128"
|
||||
],
|
||||
"CheckAliveAddress": "",
|
||||
"DnsStr": "",
|
||||
"DnsSearchStr": "",
|
||||
"Mtu": 1420
|
||||
}
|
||||
}
|
||||
```
|
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;
|
||||
}
|
@@ -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 -->
|
||||
@@ -326,11 +379,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" />
|
||||
@@ -24,7 +24,7 @@
|
||||
<div id="toasts"></div>
|
||||
|
||||
<!-- main application -->
|
||||
<div id="app"></div>
|
||||
<div id="app" class="d-flex flex-column flex-grow-1"></div>
|
||||
|
||||
<!-- vue teleport will add modals and dialogs here -->
|
||||
<div id="modals"></div>
|
||||
|
1579
frontend/package-lock.json
generated
1579
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,10 @@
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@kyvg/vue3-notification": "^3.4.1",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@vojtechlanka/vue-tags-input": "^3.1.1",
|
||||
"bootstrap": "^5.3.5",
|
||||
"bootswatch": "^5.3.5",
|
||||
"bootstrap": "^5.3.7",
|
||||
"bootswatch": "^5.3.7",
|
||||
"flag-icons": "^7.3.2",
|
||||
"ip-address": "^10.0.1",
|
||||
"is-cidr": "^5.1.1",
|
||||
@@ -29,6 +30,6 @@
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"sass-embedded": "^1.86.3",
|
||||
"vite": "6.3.2"
|
||||
"vite": "^6.3.6"
|
||||
}
|
||||
}
|
||||
|
@@ -14,6 +14,10 @@ const settings = settingsStore()
|
||||
onMounted(async () => {
|
||||
console.log("Starting WireGuard Portal frontend...");
|
||||
|
||||
// restore theme from localStorage
|
||||
const theme = localStorage.getItem('wgTheme') || 'light';
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
|
||||
await sec.LoadSecurityProperties();
|
||||
await auth.LoadProviders();
|
||||
|
||||
@@ -40,6 +44,13 @@ const switchLanguage = function (lang) {
|
||||
}
|
||||
}
|
||||
|
||||
const switchTheme = function (theme) {
|
||||
if (document.documentElement.getAttribute('data-bs-theme') !== theme) {
|
||||
localStorage.setItem('wgTheme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
}
|
||||
}
|
||||
|
||||
const languageFlag = computed(() => {
|
||||
// `this` points to the component instance
|
||||
let lang = appGlobal.$i18n.locale.toLowerCase();
|
||||
@@ -52,6 +63,7 @@ const languageFlag = computed(() => {
|
||||
uk: "ua",
|
||||
zh: "cn",
|
||||
ko: "kr",
|
||||
es: "es",
|
||||
|
||||
};
|
||||
return "fi-" + (langMap[lang] || lang);
|
||||
@@ -61,6 +73,26 @@ const companyName = ref(WGPORTAL_SITE_COMPANY_NAME);
|
||||
const wgVersion = ref(WGPORTAL_VERSION);
|
||||
const currentYear = ref(new Date().getFullYear())
|
||||
|
||||
const userDisplayName = computed(() => {
|
||||
let displayName = "Unknown";
|
||||
if (auth.IsAuthenticated) {
|
||||
if (auth.User.Firstname === "" && auth.User.Lastname === "") {
|
||||
displayName = auth.User.Identifier;
|
||||
} else if (auth.User.Firstname === "" && auth.User.Lastname !== "") {
|
||||
displayName = auth.User.Lastname;
|
||||
} else if (auth.User.Firstname !== "" && auth.User.Lastname === "") {
|
||||
displayName = auth.User.Firstname;
|
||||
} else if (auth.User.Firstname !== "" && auth.User.Lastname !== "") {
|
||||
displayName = auth.User.Firstname + " " + auth.User.Lastname;
|
||||
}
|
||||
}
|
||||
|
||||
// pad string to 20 characters so that the menu is always the same size on desktop
|
||||
if (displayName.length < 20 && window.innerWidth > 992) {
|
||||
displayName = displayName.padStart(20, "\u00A0");
|
||||
}
|
||||
return displayName;
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -85,15 +117,18 @@ const currentYear = ref(new Date().getFullYear())
|
||||
<li v-if="auth.IsAuthenticated && auth.IsAdmin" class="nav-item">
|
||||
<RouterLink :to="{ name: 'users' }" class="nav-link">{{ $t('menu.users') }}</RouterLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<RouterLink :to="{ name: 'key-generator' }" class="nav-link">{{ $t('menu.keygen') }}</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="navbar-nav d-flex justify-content-end">
|
||||
<div v-if="auth.IsAuthenticated" class="nav-item dropdown">
|
||||
<a aria-expanded="false" aria-haspopup="true" class="nav-link dropdown-toggle" data-bs-toggle="dropdown"
|
||||
href="#" role="button">{{ auth.User.Firstname }} {{ auth.User.Lastname }}</a>
|
||||
href="#" role="button">{{ userDisplayName }}</a>
|
||||
<div class="dropdown-menu">
|
||||
<RouterLink :to="{ name: 'profile' }" class="dropdown-item"><i class="fas fa-user"></i> {{ $t('menu.profile') }}</RouterLink>
|
||||
<RouterLink :to="{ name: 'settings' }" class="dropdown-item" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')"><i class="fas fa-gears"></i> {{ $t('menu.settings') }}</RouterLink>
|
||||
<RouterLink :to="{ name: 'settings' }" class="dropdown-item" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly') || settings.Setting('WebAuthnEnabled')"><i class="fas fa-gears"></i> {{ $t('menu.settings') }}</RouterLink>
|
||||
<RouterLink :to="{ name: 'audit' }" class="dropdown-item" v-if="auth.IsAdmin"><i class="fas fa-file-shield"></i> {{ $t('menu.audit') }}</RouterLink>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="#" @click.prevent="auth.Logout"><i class="fas fa-sign-out-alt"></i> {{ $t('menu.logout') }}</a>
|
||||
@@ -102,6 +137,24 @@ const currentYear = ref(new Date().getFullYear())
|
||||
<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" data-bs-theme="light">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="theme-menu" aria-expanded="false" data-bs-toggle="dropdown" data-bs-display="static" aria-label="Toggle theme">
|
||||
<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('light')" aria-pressed="false">
|
||||
<i class="fa-solid fa-sun"></i><span class="ms-2">Light</span>
|
||||
</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>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,7 +171,7 @@ const currentYear = ref(new Date().getFullYear())
|
||||
<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>
|
||||
@@ -130,6 +183,7 @@ const currentYear = ref(new Date().getFullYear())
|
||||
<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>
|
||||
@@ -137,6 +191,34 @@ const currentYear = ref(new Date().getFullYear())
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer></template>
|
||||
</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;
|
||||
}
|
||||
</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;
|
||||
|
@@ -5,6 +5,10 @@ $web-font-path: false;
|
||||
@import "bootstrap/scss/bootstrap";
|
||||
@import "bootswatch/dist/lux/bootswatch";
|
||||
|
||||
// fix strange border width bug in bootswatch 5.3
|
||||
:root {
|
||||
--bs-border-width: 1px;
|
||||
}
|
||||
|
||||
// for future use, once bootswatch supports @use
|
||||
/*
|
||||
|
@@ -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,7 @@ 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.Backend = interfaces.Prepared.Backend
|
||||
|
||||
formData.value.PublicKey = interfaces.Prepared.PublicKey
|
||||
formData.value.PrivateKey = interfaces.Prepared.PrivateKey
|
||||
@@ -99,6 +122,7 @@ 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.Backend = selectedInterface.value.Backend
|
||||
|
||||
formData.value.PublicKey = selectedInterface.value.PublicKey
|
||||
formData.value.PrivateKey = selectedInterface.value.PrivateKey
|
||||
@@ -237,6 +261,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 +277,8 @@ async function save() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +287,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,10 +306,14 @@ 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)
|
||||
close()
|
||||
@@ -290,6 +324,8 @@ async function del() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,13 +350,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>
|
||||
@@ -331,11 +376,11 @@ async function del() {
|
||||
<legend class="mt-4">{{ $t('modals.interface-edit.header-crypto') }}</legend>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.private-key.label') }}</label>
|
||||
<input v-model="formData.PrivateKey" class="form-control" :placeholder="$t('modals.interface-edit.private-key.placeholder')" required type="email">
|
||||
<input v-model="formData.PrivateKey" class="form-control" :placeholder="$t('modals.interface-edit.private-key.placeholder')" required type="text">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.public-key.label') }}</label>
|
||||
<input v-model="formData.PublicKey" class="form-control" :placeholder="$t('modals.interface-edit.public-key.placeholder')" required type="email">
|
||||
<input v-model="formData.PublicKey" class="form-control" :placeholder="$t('modals.interface-edit.public-key.placeholder')" required type="text">
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
@@ -385,12 +430,14 @@ 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-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">
|
||||
@@ -530,16 +577,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,17 +331,18 @@ async function del() {
|
||||
<legend class="mt-4">{{ $t('modals.peer-edit.header-crypto') }}</legend>
|
||||
<div class="form-group" v-if="selectedInterface.Mode === 'server'">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.private-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
|
||||
v-model="formData.PrivateKey">
|
||||
<small id="privateKeyHelp" class="form-text text-muted">{{ $t('modals.peer-edit.private-key.help') }}</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.public-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
|
||||
v-model="formData.PublicKey">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.preshared-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
|
||||
v-model="formData.PresharedKey">
|
||||
</div>
|
||||
<div class="form-group" v-if="formData.Mode === 'client'">
|
||||
@@ -349,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 }))"
|
||||
@@ -469,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>
|
||||
|
@@ -5,10 +5,12 @@ import {computed, ref, watch} from "vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
import {freshUser} from "@/helpers/models";
|
||||
import {settingsStore} from "@/stores/settings";
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const users = userStore()
|
||||
const settings = settingsStore()
|
||||
|
||||
const props = defineProps({
|
||||
userId: String,
|
||||
@@ -32,6 +34,32 @@ 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') {
|
||||
return true // nothing to validate
|
||||
}
|
||||
if (props.userId !== '#NEW#' && passwordWeak.value) {
|
||||
return false
|
||||
}
|
||||
if (props.userId === '#NEW#' && (!formData.value.Password || formData.value.Password.length < 1)) {
|
||||
return false
|
||||
}
|
||||
if (props.userId === '#NEW#' && passwordWeak.value) {
|
||||
return false
|
||||
}
|
||||
if (!formData.value.Identifier || formData.value.Identifier.length < 1) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
// functions
|
||||
|
||||
@@ -63,6 +91,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)
|
||||
@@ -76,10 +106,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()
|
||||
@@ -89,6 +123,8 @@ async function del() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +145,8 @@ async function del() {
|
||||
</div>
|
||||
<div v-if="formData.Source==='db'" class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label>
|
||||
<input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :placeholder="$t('modals.user-edit.password.placeholder')" type="text">
|
||||
<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>
|
||||
@@ -166,9 +203,15 @@ async function del() {
|
||||
</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">{{ $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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,17 +219,18 @@ async function del() {
|
||||
<legend class="mt-4">{{ $t('modals.peer-edit.header-crypto') }}</legend>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.private-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
|
||||
v-model="formData.PrivateKey">
|
||||
<small id="privateKeyHelp" class="form-text text-muted">{{ $t('modals.peer-edit.private-key.help') }}</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.public-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
|
||||
v-model="formData.PublicKey">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.preshared-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
|
||||
v-model="formData.PresharedKey">
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -282,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>
|
||||
|
@@ -5,6 +5,7 @@ export function freshInterface() {
|
||||
DisplayName: "",
|
||||
Identifier: "",
|
||||
Mode: "server",
|
||||
Backend: "local",
|
||||
|
||||
PublicKey: "",
|
||||
PrivateKey: "",
|
||||
@@ -52,6 +53,7 @@ export function freshPeer() {
|
||||
Identifier: "",
|
||||
DisplayName: "",
|
||||
UserIdentifier: "",
|
||||
UserDisplayName: "",
|
||||
InterfaceIdentifier: "",
|
||||
Disabled: false,
|
||||
ExpiresAt: null,
|
||||
|
@@ -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,
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -26,10 +26,11 @@
|
||||
"placeholder": "Bitte geben Sie Ihren Benutzernamen ein"
|
||||
},
|
||||
"password": {
|
||||
"label": "Kennwort",
|
||||
"label": "Passwort",
|
||||
"placeholder": "Bitte geben Sie Ihr Passwort ein"
|
||||
},
|
||||
"button": "Anmelden"
|
||||
"button": "Anmelden",
|
||||
"button-webauthn": "Passkey verwenden"
|
||||
},
|
||||
"menu": {
|
||||
"home": "Home",
|
||||
@@ -38,8 +39,10 @@
|
||||
"lang": "Sprache ändern",
|
||||
"profile": "Mein Profil",
|
||||
"settings": "Einstellungen",
|
||||
"audit": "Event Protokoll",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden"
|
||||
"logout": "Abmelden",
|
||||
"keygen": "Schlüsselgenerator"
|
||||
},
|
||||
"home": {
|
||||
"headline": "WireGuard® VPN Portal",
|
||||
@@ -79,77 +82,79 @@
|
||||
},
|
||||
"interfaces": {
|
||||
"headline": "Schnittstellenverwaltung",
|
||||
"headline-peers": "Current VPN Peers",
|
||||
"headline-endpoints": "Current Endpoints",
|
||||
"headline-peers": "Aktuelle VPN-Peers",
|
||||
"headline-endpoints": "Aktuelle Endpunkte",
|
||||
"no-interface": {
|
||||
"default-selection": "No Interface available",
|
||||
"headline": "No interfaces found...",
|
||||
"abstract": "Click the plus button above to create a new WireGuard interface."
|
||||
"default-selection": "Keine Schnittstelle verfügbar",
|
||||
"headline": "Keine Schnittstellen gefunden...",
|
||||
"abstract": "Klicken Sie auf die Plus-Schaltfläche oben, um eine neue WireGuard-Schnittstelle zu erstellen."
|
||||
},
|
||||
"no-peer": {
|
||||
"headline": "No peers available",
|
||||
"abstract": "Currently, there are no peers available for the selected WireGuard interface."
|
||||
"headline": "Keine Peers verfügbar",
|
||||
"abstract": "Derzeit sind keine Peers für die ausgewählte WireGuard-Schnittstelle verfügbar."
|
||||
},
|
||||
"table-heading": {
|
||||
"name": "Name",
|
||||
"user": "User",
|
||||
"user": "Benutzer",
|
||||
"ip": "IP's",
|
||||
"endpoint": "Endpoint",
|
||||
"endpoint": "Endpunkt",
|
||||
"status": "Status"
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Interface status for",
|
||||
"mode": "mode",
|
||||
"key": "Public Key",
|
||||
"endpoint": "Public Endpoint",
|
||||
"port": "Listening Port",
|
||||
"peers": "Enabled Peers",
|
||||
"total-peers": "Total Peers",
|
||||
"endpoints": "Enabled Endpoints",
|
||||
"total-endpoints": "Total Endpoints",
|
||||
"ip": "IP Address",
|
||||
"default-allowed-ip": "Default allowed IPs",
|
||||
"dns": "DNS Servers",
|
||||
"headline": "Schnittstellenstatus für",
|
||||
"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",
|
||||
"peers": "Aktive Peers",
|
||||
"total-peers": "Gesamtanzahl Peers",
|
||||
"endpoints": "Aktive Endpunkte",
|
||||
"total-endpoints": "Gesamtanzahl Endpunkte",
|
||||
"ip": "IP-Adresse",
|
||||
"default-allowed-ip": "Standard Erlaubte-IPs",
|
||||
"dns": "DNS-Server",
|
||||
"mtu": "MTU",
|
||||
"default-keep-alive": "Default Keepalive Interval",
|
||||
"button-show-config": "Show configuration",
|
||||
"button-download-config": "Download configuration",
|
||||
"button-store-config": "Store configuration for wg-quick",
|
||||
"button-edit": "Edit interface"
|
||||
"default-keep-alive": "Standard Keepalive-Intervall",
|
||||
"button-show-config": "Konfiguration anzeigen",
|
||||
"button-download-config": "Konfiguration herunterladen",
|
||||
"button-store-config": "Konfiguration für wg-quick speichern",
|
||||
"button-edit": "Schnittstelle bearbeiten"
|
||||
},
|
||||
"button-add-interface": "Add Interface",
|
||||
"button-add-peer": "Add Peer",
|
||||
"button-add-peers": "Add Multiple Peers",
|
||||
"button-show-peer": "Show Peer",
|
||||
"button-edit-peer": "Edit Peer",
|
||||
"peer-disabled": "Peer is disabled, reason:",
|
||||
"peer-expiring": "Peer is expiring at",
|
||||
"peer-connected": "Connected",
|
||||
"peer-not-connected": "Not Connected",
|
||||
"peer-handshake": "Last handshake:"
|
||||
"button-add-interface": "Schnittstelle hinzufügen",
|
||||
"button-add-peer": "Peer hinzufügen",
|
||||
"button-add-peers": "Mehrere Peers hinzufügen",
|
||||
"button-show-peer": "Peer anzeigen",
|
||||
"button-edit-peer": "Peer bearbeiten",
|
||||
"peer-disabled": "Peer ist deaktiviert, Grund:",
|
||||
"peer-expiring": "Peer läuft ab am",
|
||||
"peer-connected": "Verbunden",
|
||||
"peer-not-connected": "Nicht verbunden",
|
||||
"peer-handshake": "Letzter Handshake:"
|
||||
},
|
||||
"users": {
|
||||
"headline": "Benutzerverwaltung",
|
||||
"table-heading": {
|
||||
"id": "ID",
|
||||
"email": "E-Mail",
|
||||
"firstname": "Firstname",
|
||||
"lastname": "Lastname",
|
||||
"source": "Source",
|
||||
"firstname": "Vorname",
|
||||
"lastname": "Nachname",
|
||||
"source": "Quelle",
|
||||
"peers": "Peers",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"no-user": {
|
||||
"headline": "No users available",
|
||||
"abstract": "Currently, there are no users registered with WireGuard Portal."
|
||||
"headline": "Keine Benutzer verfügbar",
|
||||
"abstract": "Derzeit sind keine Benutzer im WireGuard-Portal registriert."
|
||||
},
|
||||
"button-add-user": "Add User",
|
||||
"button-show-user": "Show User",
|
||||
"button-edit-user": "Edit User",
|
||||
"user-disabled": "User is disabled, reason:",
|
||||
"user-locked": "Account is locked, reason:",
|
||||
"admin": "User has administrator privileges",
|
||||
"no-admin": "User has no administrator privileges"
|
||||
"button-add-user": "Benutzer hinzufügen",
|
||||
"button-show-user": "Benutzer anzeigen",
|
||||
"button-edit-user": "Benutzer bearbeiten",
|
||||
"user-disabled": "Benutzer ist deaktiviert, Grund:",
|
||||
"user-locked": "Konto ist gesperrt, Grund:",
|
||||
"admin": "Benutzer hat Administratorrechte",
|
||||
"no-admin": "Benutzer hat keine Administratorrechte"
|
||||
},
|
||||
"profile": {
|
||||
"headline": "Meine VPN-Konfigurationen",
|
||||
@@ -157,16 +162,16 @@
|
||||
"name": "Name",
|
||||
"ip": "IP's",
|
||||
"stats": "Status",
|
||||
"interface": "Server Interface"
|
||||
"interface": "Server-Schnittstelle"
|
||||
},
|
||||
"no-peer": {
|
||||
"headline": "No peers available",
|
||||
"abstract": "Currently, there are no peers associated with your user profile."
|
||||
"headline": "Keine Peers verfügbar",
|
||||
"abstract": "Derzeit sind keine Peers mit Ihrem Benutzerprofil verknüpft."
|
||||
},
|
||||
"peer-connected": "Connected",
|
||||
"button-add-peer": "Add Peer",
|
||||
"button-show-peer": "Show Peer",
|
||||
"button-edit-peer": "Edit Peer"
|
||||
"peer-connected": "Verbunden",
|
||||
"button-add-peer": "Peer hinzufügen",
|
||||
"button-show-peer": "Peer anzeigen",
|
||||
"button-edit-peer": "Peer bearbeiten"
|
||||
},
|
||||
"settings": {
|
||||
"headline": "Einstellungen",
|
||||
@@ -186,327 +191,404 @@
|
||||
"button-enable-title": "Aktivieren Sie die API, dadurch wird ein neuer Token generiert.",
|
||||
"button-enable-text": "API aktivieren",
|
||||
"api-link": "API Dokumentation"
|
||||
},
|
||||
"webauthn": {
|
||||
"headline": "Passkey-Einstellungen",
|
||||
"abstract": "Passkeys sind eine moderne Möglichkeit, Benutzer ohne Passwort zu authentifizieren. Sie werden sicher in Ihrem Browser gespeichert und können verwendet werden, um sich im WireGuard-Portal anzumelden.",
|
||||
"active-description": "Mindestens ein Passkey ist derzeit für Ihr Benutzerkonto aktiv.",
|
||||
"inactive-description": "Für Ihr Benutzerkonto sind derzeit keine Passkeys registriert. Drücken Sie die Schaltfläche unten, um einen neuen Passkey zu registrieren.",
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"created": "Erstellt",
|
||||
"actions": ""
|
||||
},
|
||||
"credentials-list": "Derzeit registrierte Passkeys",
|
||||
"modal-delete": {
|
||||
"headline": "Passkey löschen",
|
||||
"abstract": "Sind Sie sicher, dass Sie diesen Passkey löschen möchten? Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.",
|
||||
"created": "Erstellt:",
|
||||
"button-delete": "Löschen",
|
||||
"button-cancel": "Abbrechen"
|
||||
},
|
||||
"button-rename-title": "Umbenennen",
|
||||
"button-rename-text": "Passkey umbenennen.",
|
||||
"button-save-title": "Speichern",
|
||||
"button-save-text": "Neuen Namen des Passkeys speichern.",
|
||||
"button-cancel-title": "Abbrechen",
|
||||
"button-cancel-text": "Umbenennung des Passkeys abbrechen.",
|
||||
"button-delete-title": "Löschen",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"headline": "Eventprotokoll",
|
||||
"abstract": "Hier finden Sie das Eventprotokoll aller im WireGuard-Portal vorgenommenen Aktionen.",
|
||||
"no-entries": {
|
||||
"headline": "Keine Protokolleinträge verfügbar",
|
||||
"abstract": "Derzeit sind keine Eventprotokolle aufgezeichnet."
|
||||
},
|
||||
"entries-headline": "Protokolleinträge",
|
||||
"table-heading": {
|
||||
"id": "#",
|
||||
"time": "Zeit",
|
||||
"user": "Benutzer",
|
||||
"severity": "Schweregrad",
|
||||
"origin": "Ursprung",
|
||||
"message": "Nachricht"
|
||||
}
|
||||
},
|
||||
"keygen": {
|
||||
"headline": "WireGuard Key Generator",
|
||||
"abstract": "Hier können Sie WireGuard Schlüsselpaare generieren. Die Schlüssel werden lokal auf Ihrem Computer generiert und niemals an den Server gesendet.",
|
||||
"headline-keypair": "Neues Schlüsselpaar",
|
||||
"headline-preshared-key": "Neuer Pre-Shared Key",
|
||||
"button-generate": "Erzeugen",
|
||||
"private-key": {
|
||||
"label": "Privater Schlüssel",
|
||||
"placeholder": "Der private Schlüssel"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Öffentlicher Schlüssel",
|
||||
"placeholder": "Der öffentliche Schlüssel"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "Pre-Shared Key",
|
||||
"placeholder": "Der geteilte Schlüssel"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
"user-view": {
|
||||
"headline": "User Account:",
|
||||
"tab-user": "Information",
|
||||
"headline": "Benutzerkonto:",
|
||||
"tab-user": "Informationen",
|
||||
"tab-peers": "Peers",
|
||||
"headline-info": "User Information:",
|
||||
"headline-notes": "Notes:",
|
||||
"headline-info": "Benutzerinformationen:",
|
||||
"headline-notes": "Notizen:",
|
||||
"email": "E-Mail",
|
||||
"firstname": "Firstname",
|
||||
"lastname": "Lastname",
|
||||
"phone": "Phone number",
|
||||
"department": "Department",
|
||||
"disabled": "Account Disabled",
|
||||
"locked": "Account Locked",
|
||||
"no-peers": "User has no associated peers.",
|
||||
"firstname": "Vorname",
|
||||
"lastname": "Nachname",
|
||||
"phone": "Telefonnummer",
|
||||
"department": "Abteilung",
|
||||
"api-enabled": "API-Zugriff",
|
||||
"disabled": "Konto deaktiviert",
|
||||
"locked": "Konto gesperrt",
|
||||
"no-peers": "Benutzer hat keine zugeordneten Peers.",
|
||||
"peers": {
|
||||
"name": "Name",
|
||||
"interface": "Interface",
|
||||
"interface": "Schnittstelle",
|
||||
"ip": "IP's"
|
||||
}
|
||||
},
|
||||
"user-edit": {
|
||||
"headline-edit": "Edit user:",
|
||||
"headline-new": "New user",
|
||||
"header-general": "General",
|
||||
"header-personal": "User Information",
|
||||
"header-notes": "Notes",
|
||||
"header-state": "State",
|
||||
"headline-edit": "Benutzer bearbeiten:",
|
||||
"headline-new": "Neuer Benutzer",
|
||||
"header-general": "Allgemein",
|
||||
"header-personal": "Benutzerinformationen",
|
||||
"header-notes": "Notizen",
|
||||
"header-state": "Status",
|
||||
"identifier": {
|
||||
"label": "Identifier",
|
||||
"placeholder": "The unique user identifier"
|
||||
"label": "Kennung",
|
||||
"placeholder": "Die eindeutige Benutzerkennung"
|
||||
},
|
||||
"source": {
|
||||
"label": "Source",
|
||||
"placeholder": "The user source"
|
||||
"label": "Quelle",
|
||||
"placeholder": "Die Benutzerquelle"
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"placeholder": "A super secret password",
|
||||
"description": "Leave this field blank to keep current password."
|
||||
"label": "Passwort",
|
||||
"placeholder": "Ein super geheimes Passwort",
|
||||
"description": "Lassen Sie dieses Feld leer, um das aktuelle Passwort beizubehalten.",
|
||||
"too-weak": "Das Passwort entspricht nicht den Sicherheitsanforderungen."
|
||||
},
|
||||
"email": {
|
||||
"label": "Email",
|
||||
"placeholder": "The email address"
|
||||
"label": "E-Mail",
|
||||
"placeholder": "Die E-Mail-Adresse"
|
||||
},
|
||||
"phone": {
|
||||
"label": "Phone",
|
||||
"placeholder": "The phone number"
|
||||
"label": "Telefon",
|
||||
"placeholder": "Die Telefonnummer"
|
||||
},
|
||||
"department": {
|
||||
"label": "Department",
|
||||
"placeholder": "The department"
|
||||
"label": "Abteilung",
|
||||
"placeholder": "Die Abteilung"
|
||||
},
|
||||
"firstname": {
|
||||
"label": "Firstname",
|
||||
"placeholder": "Firstname"
|
||||
"label": "Vorname",
|
||||
"placeholder": "Vorname"
|
||||
},
|
||||
"lastname": {
|
||||
"label": "Lastname",
|
||||
"placeholder": "Lastname"
|
||||
"label": "Nachname",
|
||||
"placeholder": "Nachname"
|
||||
},
|
||||
"notes": {
|
||||
"label": "Notes",
|
||||
"label": "Notizen",
|
||||
"placeholder": ""
|
||||
},
|
||||
"disabled": {
|
||||
"label": "Disabled (no WireGuard connection and no login possible)"
|
||||
"label": "Deaktiviert (keine WireGuard-Verbindung und kein Login möglich)"
|
||||
},
|
||||
"locked": {
|
||||
"label": "Locked (no login possible, WireGuard connections still work)"
|
||||
"label": "Gesperrt (kein Login möglich, WireGuard-Verbindungen funktionieren weiterhin)"
|
||||
},
|
||||
"admin": {
|
||||
"label": "Is Admin"
|
||||
"label": "Ist Administrator"
|
||||
}
|
||||
},
|
||||
"interface-view": {
|
||||
"headline": "Config for Interface:"
|
||||
"headline": "Konfiguration für Schnittstelle:"
|
||||
},
|
||||
"interface-edit": {
|
||||
"headline-edit": "Edit Interface:",
|
||||
"headline-new": "New Interface",
|
||||
"tab-interface": "Interface",
|
||||
"tab-peerdef": "Peer Defaults",
|
||||
"header-general": "General",
|
||||
"header-network": "Network",
|
||||
"header-crypto": "Cryptography",
|
||||
"header-hooks": "Interface Hooks",
|
||||
"headline-edit": "Schnittstelle bearbeiten:",
|
||||
"headline-new": "Neue Schnittstelle",
|
||||
"tab-interface": "Schnittstelle",
|
||||
"tab-peerdef": "Peer-Standardeinstellungen",
|
||||
"header-general": "Allgemein",
|
||||
"header-network": "Netzwerk",
|
||||
"header-crypto": "Kryptografie",
|
||||
"header-hooks": "Schnittstellen-Hooks",
|
||||
"header-peer-hooks": "Hooks",
|
||||
"header-state": "State",
|
||||
"header-state": "Status",
|
||||
"identifier": {
|
||||
"label": "Identifier",
|
||||
"placeholder": "The unique interface identifier"
|
||||
"label": "Kennung",
|
||||
"placeholder": "Die eindeutige Schnittstellenkennung"
|
||||
},
|
||||
"mode": {
|
||||
"label": "Interface Mode",
|
||||
"server": "Server Mode",
|
||||
"client": "Client Mode",
|
||||
"any": "Unknown Mode"
|
||||
"label": "Schnittstellenmodus",
|
||||
"server": "Server-Modus",
|
||||
"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": "Display Name",
|
||||
"placeholder": "The descriptive name for the interface"
|
||||
"label": "Anzeigename",
|
||||
"placeholder": "Der beschreibende Name für die Schnittstelle"
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Private Key",
|
||||
"placeholder": "The private key"
|
||||
"label": "Privater Schlüssel",
|
||||
"placeholder": "Der private Schlüssel"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Public Key",
|
||||
"placeholder": "The public key"
|
||||
"label": "Öffentlicher Schlüssel",
|
||||
"placeholder": "Der öffentliche Schlüssel"
|
||||
},
|
||||
"ip": {
|
||||
"label": "IP Addresses",
|
||||
"placeholder": "IP Addresses (CIDR format)"
|
||||
"label": "IP-Adressen",
|
||||
"placeholder": "IP-Adressen (CIDR-Format)"
|
||||
},
|
||||
"listen-port": {
|
||||
"label": "Listen Port",
|
||||
"placeholder": "The listening port"
|
||||
"label": "Port",
|
||||
"placeholder": "Der Port der WireGuard Schnittstelle"
|
||||
},
|
||||
"dns": {
|
||||
"label": "DNS Server",
|
||||
"placeholder": "The DNS servers that should be used"
|
||||
"label": "DNS-Server",
|
||||
"placeholder": "Die zu verwendenden DNS-Server"
|
||||
},
|
||||
"dns-search": {
|
||||
"label": "DNS Search Domains",
|
||||
"placeholder": "DNS search prefixes"
|
||||
"label": "DNS-Suchdomänen",
|
||||
"placeholder": "DNS-Suchpräfixe"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "The interface MTU (0 = keep default)"
|
||||
"placeholder": "Die Schnittstellen-MTU (0 = Standard beibehalten)"
|
||||
},
|
||||
"firewall-mark": {
|
||||
"label": "Firewall Mark",
|
||||
"placeholder": "Firewall mark that is applied to outgoing traffic. (0 = automatic)"
|
||||
"label": "Firewall-Markierung",
|
||||
"placeholder": "Firewall-Markierung, die auf ausgehenden Datenverkehr angewendet wird. (0 = automatisch)"
|
||||
},
|
||||
"routing-table": {
|
||||
"label": "Routing Table",
|
||||
"placeholder": "The routing table ID",
|
||||
"description": "Special cases: off = do not manage routes, 0 = automatic"
|
||||
"label": "Routing-Tabelle",
|
||||
"placeholder": "Die Routing-Tabellen-ID",
|
||||
"description": "Spezialfälle: off = Routen nicht verwalten, 0 = automatisch"
|
||||
},
|
||||
"pre-up": {
|
||||
"label": "Pre-Up",
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
|
||||
},
|
||||
"post-up": {
|
||||
"label": "Post-Up",
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
|
||||
},
|
||||
"pre-down": {
|
||||
"label": "Pre-Down",
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
|
||||
},
|
||||
"post-down": {
|
||||
"label": "Post-Down",
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
|
||||
},
|
||||
"disabled": {
|
||||
"label": "Interface Disabled"
|
||||
"label": "Schnittstelle deaktiviert"
|
||||
},
|
||||
"save-config": {
|
||||
"label": "Automatically save wg-quick config"
|
||||
"label": "wg-quick Konfiguration automatisch speichern"
|
||||
},
|
||||
"defaults": {
|
||||
"endpoint": {
|
||||
"label": "Endpoint Address",
|
||||
"placeholder": "Endpoint Address",
|
||||
"description": "The endpoint address that peers will connect to. (e.g. wg.example.com or wg.example.com:51820)"
|
||||
"label": "Endpunktadresse",
|
||||
"placeholder": "Endpunktadresse",
|
||||
"description": "Die Endpunktadresse, mit der sich Peers verbinden. (z.B. wg.example.com oder wg.example.com:51820)"
|
||||
},
|
||||
"networks": {
|
||||
"label": "IP Networks",
|
||||
"placeholder": "Network Addresses",
|
||||
"description": "Peers will get IP addresses from those subnets."
|
||||
"label": "IP-Netzwerke",
|
||||
"placeholder": "Netzwerkadressen",
|
||||
"description": "Peers erhalten IP-Adressen aus diesen Subnetzen."
|
||||
},
|
||||
"allowed-ip": {
|
||||
"label": "Allowed IP Addresses",
|
||||
"placeholder": "Default Allowed IP Addresses"
|
||||
"label": "Erlaubte IP-Adressen",
|
||||
"placeholder": "Erlaubte IP-Adressen für Peers"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "The client MTU (0 = keep default)"
|
||||
"placeholder": "Die Client-MTU (0 = Standard beibehalten)"
|
||||
},
|
||||
"keep-alive": {
|
||||
"label": "Keep Alive Interval",
|
||||
"placeholder": "Persistent Keepalive (0 = default)"
|
||||
"label": "Keepalive-Intervall",
|
||||
"placeholder": "Persistentes Keepalive (0 = Standard)"
|
||||
}
|
||||
},
|
||||
|
||||
"button-apply-defaults": "Apply Peer Defaults"
|
||||
"button-apply-defaults": "Peer-Standardeinstellungen anwenden"
|
||||
},
|
||||
"peer-view": {
|
||||
"headline-peer": "Peer:",
|
||||
"headline-endpoint": "Endpoint:",
|
||||
"section-info": "Peer Information",
|
||||
"section-status": "Current Status",
|
||||
"section-config": "Configuration",
|
||||
"identifier": "Identifier",
|
||||
"ip": "IP Addresses",
|
||||
"user": "Associated User",
|
||||
"notes": "Notes",
|
||||
"expiry-status": "Expires At",
|
||||
"disabled-status": "Disabled At",
|
||||
"traffic": "Traffic",
|
||||
"connection-status": "Connection Stats",
|
||||
"upload": "Uploaded Bytes (from Server to Peer)",
|
||||
"download": "Downloaded Bytes (from Peer to Server)",
|
||||
"pingable": "Is Pingable",
|
||||
"handshake": "Last Handshake",
|
||||
"connected-since": "Connected since",
|
||||
"endpoint": "Endpoint",
|
||||
"button-download": "Download configuration",
|
||||
"button-email": "Send configuration via E-Mail"
|
||||
"headline-endpoint": "Endpunkt:",
|
||||
"section-info": "Peer-Informationen",
|
||||
"section-status": "Aktueller Status",
|
||||
"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",
|
||||
"disabled-status": "Deaktiviert am",
|
||||
"traffic": "Datenverkehr",
|
||||
"connection-status": "Verbindungsstatistiken",
|
||||
"upload": "Hochgeladene Bytes (vom Server zum Peer)",
|
||||
"download": "Heruntergeladene Bytes (vom Peer zum Server)",
|
||||
"pingable": "Pingbar",
|
||||
"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",
|
||||
"style-label": "Konfigurationsformat"
|
||||
},
|
||||
"peer-edit": {
|
||||
"headline-edit-peer": "Edit peer:",
|
||||
"headline-edit-endpoint": "Edit endpoint:",
|
||||
"headline-new-peer": "Create peer",
|
||||
"headline-new-endpoint": "Create endpoint",
|
||||
"header-general": "General",
|
||||
"header-network": "Network",
|
||||
"header-crypto": "Cryptography",
|
||||
"header-hooks": "Hooks (Executed on Peer)",
|
||||
"header-state": "State",
|
||||
"headline-edit-peer": "Peer bearbeiten:",
|
||||
"headline-edit-endpoint": "Endpunkt bearbeiten:",
|
||||
"headline-new-peer": "Peer erstellen",
|
||||
"headline-new-endpoint": "Endpunkt erstellen",
|
||||
"header-general": "Allgemein",
|
||||
"header-network": "Netzwerk",
|
||||
"header-crypto": "Kryptografie",
|
||||
"header-hooks": "Hooks (beim Peer ausgeführt)",
|
||||
"header-state": "Status",
|
||||
"display-name": {
|
||||
"label": "Display Name",
|
||||
"placeholder": "The descriptive name for the peer"
|
||||
"label": "Anzeigename",
|
||||
"placeholder": "Der beschreibende Name für den Peer"
|
||||
},
|
||||
"linked-user": {
|
||||
"label": "Linked User",
|
||||
"placeholder": "The user account which owns this peer"
|
||||
"label": "Verknüpfter Benutzer",
|
||||
"placeholder": "Das Benutzerkonto, dem dieser Peer gehört"
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Private Key",
|
||||
"placeholder": "The private key"
|
||||
"label": "Privater Schlüssel",
|
||||
"placeholder": "Der private Schlüssel",
|
||||
"help": "Der private Schlüssel wird sicher auf dem Server gespeichert. Wenn der Benutzer bereits eine Kopie besitzt, kann dieses Feld entfallen. Der Server funktioniert auch ausschließlich mit dem öffentlichen Schlüssel des Peers."
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Public Key",
|
||||
"placeholder": "The public key"
|
||||
"label": "Öffentlicher Schlüssel",
|
||||
"placeholder": "Der öffentliche Schlüssel"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "Preshared Key",
|
||||
"placeholder": "Optional pre-shared key"
|
||||
"label": "Pre-Shared Key",
|
||||
"placeholder": "Optionaler geteilter Schlüssel"
|
||||
},
|
||||
"endpoint-public-key": {
|
||||
"label": "Endpoint public Key",
|
||||
"placeholder": "The public key of the remote endpoint"
|
||||
"label": "Öffentlicher Endpunktschlüssel",
|
||||
"placeholder": "Der öffentliche Schlüssel des entfernten Endpunkts"
|
||||
},
|
||||
"endpoint": {
|
||||
"label": "Endpoint Address",
|
||||
"placeholder": "The address of the remote endpoint"
|
||||
"label": "Endpunktadresse",
|
||||
"placeholder": "Die Adresse des entfernten Endpunkts"
|
||||
},
|
||||
"ip": {
|
||||
"label": "IP Addresses",
|
||||
"placeholder": "IP Addresses (CIDR format)"
|
||||
"label": "IP-Adressen",
|
||||
"placeholder": "IP-Adressen (CIDR-Format)"
|
||||
},
|
||||
"allowed-ip": {
|
||||
"label": "Allowed IP Addresses",
|
||||
"placeholder": "Allowed IP Addresses (CIDR format)"
|
||||
"label": "Erlaubte IP-Adressen",
|
||||
"placeholder": "Erlaubte IP-Adressen (CIDR-Format)"
|
||||
},
|
||||
"extra-allowed-ip": {
|
||||
"label": "Extra allowed IP Addresses",
|
||||
"placeholder": "Extra allowed IP's (Server Sided)",
|
||||
"description": "Those IP's will be added on the remote WireGuard interface as allowed IP's."
|
||||
"label": "Zusätzliche erlaubte IP-Adressen",
|
||||
"placeholder": "Zusätzliche erlaubte IP's (Server-seitig)",
|
||||
"description": "Diese IPs werden an der entfernten WireGuard-Schnittstelle als erlaubte IPs hinzugefügt."
|
||||
},
|
||||
"dns": {
|
||||
"label": "DNS Server",
|
||||
"placeholder": "The DNS servers that should be used"
|
||||
"label": "DNS-Server",
|
||||
"placeholder": "Die zu verwendenden DNS-Server"
|
||||
},
|
||||
"dns-search": {
|
||||
"label": "DNS Search Domains",
|
||||
"placeholder": "DNS search prefixes"
|
||||
"label": "DNS-Suchdomänen",
|
||||
"placeholder": "DNS-Suchpräfixe"
|
||||
},
|
||||
"keep-alive": {
|
||||
"label": "Keep Alive Interval",
|
||||
"placeholder": "Persistent Keepalive (0 = default)"
|
||||
"label": "Keepalive-Intervall",
|
||||
"placeholder": "Persistentes Keepalive (0 = Standard)"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "The client MTU (0 = keep default)"
|
||||
"placeholder": "Die Client-MTU (0 = Standard beibehalten)"
|
||||
},
|
||||
"pre-up": {
|
||||
"label": "Pre-Up",
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
|
||||
},
|
||||
"post-up": {
|
||||
"label": "Post-Up",
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
|
||||
},
|
||||
"pre-down": {
|
||||
"label": "Pre-Down",
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
|
||||
},
|
||||
"post-down": {
|
||||
"label": "Post-Down",
|
||||
"placeholder": "One or multiple bash commands separated by ;"
|
||||
"placeholder": "Ein oder mehrere Bash-Befehle, getrennt durch ;"
|
||||
},
|
||||
"disabled": {
|
||||
"label": "Peer Disabled"
|
||||
"label": "Peer deaktiviert"
|
||||
},
|
||||
"ignore-global": {
|
||||
"label": "Ignore global settings"
|
||||
"label": "Globale Einstellungen ignorieren"
|
||||
},
|
||||
"expires-at": {
|
||||
"label": "Expiry date"
|
||||
"label": "Ablaufdatum"
|
||||
}
|
||||
},
|
||||
"peer-multi-create": {
|
||||
"headline-peer": "Create multiple peers",
|
||||
"headline-endpoint": "Create multiple endpoints",
|
||||
"headline-peer": "Mehrere Peers erstellen",
|
||||
"headline-endpoint": "Mehrere Endpunkte erstellen",
|
||||
"identifiers": {
|
||||
"label": "User Identifiers",
|
||||
"placeholder": "User Identifiers",
|
||||
"description": "A user identifier (the username) for which a peer should be created."
|
||||
"label": "Benutzerkennungen",
|
||||
"placeholder": "Benutzerkennungen",
|
||||
"description": "Eine Benutzerkennung (der Benutzername), für die ein Peer erstellt werden soll."
|
||||
},
|
||||
"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-endpoint": "Endpunkt:",
|
||||
"label": "Anzeigename-Präfix",
|
||||
"placeholder": "Das Präfix",
|
||||
"description": "Ein Präfix, das dem Anzeigenamen des Peers hinzugefügt wird."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -29,7 +29,8 @@
|
||||
"label": "Password",
|
||||
"placeholder": "Please enter your password"
|
||||
},
|
||||
"button": "Sign in"
|
||||
"button": "Sign in",
|
||||
"button-webauthn": "Use Passkey"
|
||||
},
|
||||
"menu": {
|
||||
"home": "Home",
|
||||
@@ -40,7 +41,8 @@
|
||||
"settings": "Settings",
|
||||
"audit": "Audit Log",
|
||||
"login": "Login",
|
||||
"logout": "Logout"
|
||||
"logout": "Logout",
|
||||
"keygen": "Key Generator"
|
||||
},
|
||||
"home": {
|
||||
"headline": "WireGuard® VPN Portal",
|
||||
@@ -100,7 +102,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",
|
||||
@@ -187,6 +191,35 @@
|
||||
"button-enable-title": "Enable API, this will generate a new token.",
|
||||
"button-enable-text": "Enable API",
|
||||
"api-link": "API Documentation"
|
||||
},
|
||||
"webauthn": {
|
||||
"headline": "Passkey Settings",
|
||||
"abstract": "Passkeys are a modern way to authenticate users without the need for passwords. They are stored securely in your browser and can be used to log in to the WireGuard Portal.",
|
||||
"active-description": "At least one passkey is currently active for your user account.",
|
||||
"inactive-description": "No passkeys are currently registered for your user account. Press the button below to register a new passkey.",
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"created": "Created",
|
||||
"actions": ""
|
||||
},
|
||||
"credentials-list": "Currently registered Passkeys",
|
||||
"modal-delete": {
|
||||
"headline": "Delete Passkey",
|
||||
"abstract": "Are you sure you want to delete this passkey? You will not be able to log in with this passkey anymore.",
|
||||
"created": "Created:",
|
||||
"button-delete": "Delete",
|
||||
"button-cancel": "Cancel"
|
||||
},
|
||||
"button-rename-title": "Rename",
|
||||
"button-rename-text": "Rename the passkey.",
|
||||
"button-save-title": "Save",
|
||||
"button-save-text": "Save the new name of the passkey.",
|
||||
"button-cancel-title": "Cancel",
|
||||
"button-cancel-text": "Cancel the renaming of the passkey.",
|
||||
"button-delete-title": "Delete",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
@@ -206,6 +239,25 @@
|
||||
"message": "Message"
|
||||
}
|
||||
},
|
||||
"keygen": {
|
||||
"headline": "WireGuard Key Generator",
|
||||
"abstract": "Generate a new WireGuard keys. The keys are generated in your local browser and are never sent to the server.",
|
||||
"headline-keypair": "New Key Pair",
|
||||
"headline-preshared-key": "New Preshared Key",
|
||||
"button-generate": "Generate",
|
||||
"private-key": {
|
||||
"label": "Private Key",
|
||||
"placeholder": "The private key"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Public Key",
|
||||
"placeholder": "The public key"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "Preshared Key",
|
||||
"placeholder": "The pre-shared key"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
"user-view": {
|
||||
"headline": "User Account:",
|
||||
@@ -246,7 +298,8 @@
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"placeholder": "A super secret password",
|
||||
"description": "Leave this field blank to keep current password."
|
||||
"description": "Leave this field blank to keep current password.",
|
||||
"too-weak": "The password is too weak. Please use a stronger password."
|
||||
},
|
||||
"email": {
|
||||
"label": "Email",
|
||||
@@ -306,6 +359,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"
|
||||
@@ -404,6 +462,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",
|
||||
@@ -416,8 +476,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:",
|
||||
@@ -439,7 +502,8 @@
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Private Key",
|
||||
"placeholder": "The private key"
|
||||
"placeholder": "The private key",
|
||||
"help": "The private key is stored securely on the server. If the user already holds a copy, you may omit this field. The server still functions exclusively with the peer’s public key."
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Public Key",
|
||||
|
587
frontend/src/lang/translations/es.json
Normal file
587
frontend/src/lang/translations/es.json
Normal file
@@ -0,0 +1,587 @@
|
||||
{
|
||||
"languages": {
|
||||
"es": "Español"
|
||||
},
|
||||
"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": {
|
||||
"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": "IP's",
|
||||
"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",
|
||||
"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",
|
||||
"source": "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": "IP's",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"depeertment": "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": "IP's"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"depeertment": {
|
||||
"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:"
|
||||
},
|
||||
"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",
|
||||
"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": "Es Alcanzable (Ping)",
|
||||
"handshake": "Último Handshake",
|
||||
"connected-since": "Conectado desde",
|
||||
"endpoint": "Endpoint",
|
||||
"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": "Cave 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"
|
||||
},
|
||||
"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 peera Mostrar",
|
||||
"placeholder": "El prefijo",
|
||||
"description": "Un prefijo que se agregará al nombre mostrado 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",
|
||||
|
@@ -100,7 +100,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "인터페이스 상태:",
|
||||
"mode": "모드",
|
||||
"backend": "백엔드",
|
||||
"key": "공개 키",
|
||||
"endpoint": "공개 엔드포인트",
|
||||
"port": "수신 포트",
|
||||
|
@@ -20,14 +20,14 @@
|
||||
"delete": "Eliminar"
|
||||
},
|
||||
"login": {
|
||||
"headline": "Por favor, inicie sessão",
|
||||
"headline": "Por favor, inicie a sessão",
|
||||
"username": {
|
||||
"label": "Nome de utilizador",
|
||||
"placeholder": "Introduza o seu nome de utilizador"
|
||||
"placeholder": "Por favor, insira o seu nome de utilizador"
|
||||
},
|
||||
"password": {
|
||||
"label": "Palavra-passe",
|
||||
"placeholder": "Introduza a sua palavra-passe"
|
||||
"placeholder": "Por favor, insira a sua palavra-passe"
|
||||
},
|
||||
"button": "Iniciar sessão"
|
||||
},
|
||||
@@ -40,143 +40,513 @@
|
||||
"settings": "Definições",
|
||||
"audit": "Registo de Auditoria",
|
||||
"login": "Iniciar Sessão",
|
||||
"logout": "Terminar Sessão"
|
||||
"logout": "Terminar Sessão",
|
||||
"keygen": "Gerador de Chave"
|
||||
},
|
||||
"home": {
|
||||
"title": "Início",
|
||||
"card": {
|
||||
"interfaces": "Interfaces",
|
||||
"users": "Utilizadores"
|
||||
"headline": "WireGuard® Portal VPN",
|
||||
"info-headline": "Mais Informações",
|
||||
"abstract": "WireGuard® é uma VPN extremamente simples, mas rápida e moderna que utiliza criptografia de última geração. O seu objetivo é ser mais rápida, simples, leve e útil que o IPsec, enquanto evita grandes dores de cabeça. Pretende ser consideravelmente mais eficiente que o OpenVPN.",
|
||||
"installation": {
|
||||
"box-header": "Instalação do WireGuard",
|
||||
"headline": "Instalação",
|
||||
"content": "As instruções de instalação para o software cliente podem ser encontradas no site oficial do WireGuard.",
|
||||
"button": "Abrir Instruções"
|
||||
},
|
||||
"about-wg": {
|
||||
"box-header": "Sobre o WireGuard",
|
||||
"headline": "Sobre",
|
||||
"content": "WireGuard® é uma VPN extremamente simples, mas rápida e moderna que utiliza criptografia de última geração.",
|
||||
"button": "Mais"
|
||||
},
|
||||
"about-portal": {
|
||||
"box-header": "Sobre o WireGuard Portal",
|
||||
"headline": "WireGuard Portal",
|
||||
"content": "WireGuard Portal é um portal web de configuração simples para o WireGuard.",
|
||||
"button": "Mais"
|
||||
},
|
||||
"profiles": {
|
||||
"headline": "Perfis VPN",
|
||||
"abstract": "Pode aceder e baixar as suas configurações pessoais de VPN através do seu Perfil de Utilizador.",
|
||||
"content": "Para encontrar todos os seus perfis configurados, clique no botão abaixo.",
|
||||
"button": "Abrir meu perfil"
|
||||
},
|
||||
"admin": {
|
||||
"headline": "Área de Administração",
|
||||
"abstract": "Na área de administração, pode gerir os peers do WireGuard, a interface do servidor e os utilizadores que têm permissão para aceder ao Portal WireGuard.",
|
||||
"content": "",
|
||||
"button-admin": "Abrir Administração do Servidor",
|
||||
"button-user": "Abrir Administração de Utilizadores"
|
||||
}
|
||||
},
|
||||
"interfaces": {
|
||||
"title": "Interfaces",
|
||||
"create": "Criar Interface",
|
||||
"name": "Nome",
|
||||
"address": "Endereço",
|
||||
"listen-port": "Porta de Escuta",
|
||||
"public-key": "Chave Pública",
|
||||
"private-key": "Chave Privada",
|
||||
"actions": "Ações",
|
||||
"delete-dialog": {
|
||||
"title": "Eliminar Interface",
|
||||
"text": "Tem a certeza de que deseja eliminar a interface '{{name}}'?"
|
||||
"headline": "Administração de Interfaces",
|
||||
"headline-peers": "Peers VPN Atuais",
|
||||
"headline-endpoints": "Endpoints Atuais",
|
||||
"no-interface": {
|
||||
"default-selection": "Nenhuma interface disponível",
|
||||
"headline": "Nenhuma interface encontrada...",
|
||||
"abstract": "Clique no botão + acima para criar uma nova interface WireGuard."
|
||||
},
|
||||
"form": {
|
||||
"name": {
|
||||
"label": "Nome",
|
||||
"placeholder": "Introduza um nome exclusivo para a interface"
|
||||
},
|
||||
"address": {
|
||||
"label": "Endereço",
|
||||
"placeholder": "Introduza um endereço válido (ex: 10.0.0.1/24)"
|
||||
},
|
||||
"listen-port": {
|
||||
"label": "Porta de Escuta",
|
||||
"placeholder": "Introduza a porta onde o WireGuard irá escutar (ex: 51820)"
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Chave Privada",
|
||||
"placeholder": "Será gerada automaticamente se não for fornecida"
|
||||
}
|
||||
}
|
||||
"no-peer": {
|
||||
"headline": "Nenhum peer disponível",
|
||||
"abstract": "Atualmente, não há peers disponíveis para a interface WireGuard selecionada."
|
||||
},
|
||||
"table-heading": {
|
||||
"name": "Nome",
|
||||
"user": "Utilizador",
|
||||
"ip": "IPs",
|
||||
"endpoint": "Endpoint",
|
||||
"status": "Status"
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Status da interface para",
|
||||
"mode": "backend",
|
||||
"key": "Chave Pública",
|
||||
"endpoint": "Endpoint Público",
|
||||
"port": "Porta de Escuta",
|
||||
"peers": "Peers Ativados",
|
||||
"total-peers": "Total de Peers",
|
||||
"endpoints": "Endpoints Ativados",
|
||||
"total-endpoints": "Total de Endpoints",
|
||||
"ip": "Endereço IP",
|
||||
"default-allowed-ip": "IPs permitidos por padrão",
|
||||
"dns": "Servidores DNS",
|
||||
"mtu": "MTU",
|
||||
"default-keep-alive": "Intervalo de Keepalive Padrão",
|
||||
"button-show-config": "Mostrar configuração",
|
||||
"button-download-config": "Baixar configuração",
|
||||
"button-store-config": "Armazenar configuração para wg-quick",
|
||||
"button-edit": "Editar interface"
|
||||
},
|
||||
"button-add-interface": "Adicionar Interface",
|
||||
"button-add-peer": "Adicionar Peer",
|
||||
"button-add-peers": "Adicionar Vários Peers",
|
||||
"button-show-peer": "Mostrar Peer",
|
||||
"button-edit-peer": "Editar Peer",
|
||||
"peer-disabled": "Peer desativado, razão:",
|
||||
"peer-expiring": "Peer expira em",
|
||||
"peer-connected": "Conectado",
|
||||
"peer-not-connected": "Não Conectado",
|
||||
"peer-handshake": "Último handshake:"
|
||||
},
|
||||
"users": {
|
||||
"title": "Utilizadores",
|
||||
"create": "Criar Utilizador",
|
||||
"name": "Nome",
|
||||
"email": "Email",
|
||||
"enabled": "Ativo",
|
||||
"is-admin": "Administrador",
|
||||
"actions": "Ações",
|
||||
"edit": "Editar",
|
||||
"delete-dialog": {
|
||||
"title": "Eliminar Utilizador",
|
||||
"text": "Tem a certeza de que deseja eliminar o utilizador '{{nome}}'?"
|
||||
"headline": "Administração de Utilizadores",
|
||||
"table-heading": {
|
||||
"id": "ID",
|
||||
"email": "E-Mail",
|
||||
"firstname": "Primeiro Nome",
|
||||
"lastname": "Último Nome",
|
||||
"source": "Fonte",
|
||||
"peers": "Peers",
|
||||
"admin": "Administrador"
|
||||
},
|
||||
"form": {
|
||||
"name": {
|
||||
"label": "Nome",
|
||||
"placeholder": "Introduza o nome do utilizador"
|
||||
"no-user": {
|
||||
"headline": "Nenhum utilizador disponível",
|
||||
"abstract": "Atualmente, não há utilizadores registados no Portal WireGuard."
|
||||
},
|
||||
"button-add-user": "Adicionar Utilizador",
|
||||
"button-show-user": "Mostrar Utilizador",
|
||||
"button-edit-user": "Editar Utilizador",
|
||||
"user-disabled": "Utilizador desativado, razão:",
|
||||
"user-locked": "Conta bloqueada, razão:",
|
||||
"admin": "O utilizador tem privilégios de administrador",
|
||||
"no-admin": "O utilizador não tem privilégios de administrador"
|
||||
},
|
||||
"profile": {
|
||||
"headline": "Os Meus Peers VPN",
|
||||
"table-heading": {
|
||||
"name": "Nome",
|
||||
"ip": "IPs",
|
||||
"stats": "Status",
|
||||
"interface": "Interface do Servidor"
|
||||
},
|
||||
"no-peer": {
|
||||
"headline": "Nenhum peer disponível",
|
||||
"abstract": "Atualmente, não há peers associados ao seu perfil de utilizador."
|
||||
},
|
||||
"peer-connected": "Conectado",
|
||||
"button-add-peer": "Adicionar Peer",
|
||||
"button-show-peer": "Mostrar Peer",
|
||||
"button-edit-peer": "Editar Peer"
|
||||
},
|
||||
"settings": {
|
||||
"headline": "Definições",
|
||||
"abstract": "Aqui pode alterar suas Definições pessoais.",
|
||||
"api": {
|
||||
"headline": "Definições da API",
|
||||
"abstract": "Aqui pode configurar as definições da API RESTful.",
|
||||
"active-description": "A API está atualmente ativa para a sua conta de utilizador. Todos os pedidos para a API são autenticadas com Basic Auth. Use as seguintes credenciais para autenticação.",
|
||||
"inactive-description": "A API está atualmente inativa. Pressione o botão abaixo para ativá-la.",
|
||||
"user-label": "Nome de utilizador API:",
|
||||
"user-placeholder": "O utilizador da API",
|
||||
"token-label": "Senha da API:",
|
||||
"token-placeholder": "O token da API",
|
||||
"token-created-label": "Acesso API concedido em: ",
|
||||
"button-disable-title": "Desativar API, invalidando o token atual.",
|
||||
"button-disable-text": "Desativar API",
|
||||
"button-enable-title": "Ativar API, gerando um novo token.",
|
||||
"button-enable-text": "Ativar API",
|
||||
"api-link": "Documentação da API"
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"headline": "Registo de Auditoria",
|
||||
"abstract": "Aqui pode encontrar o registo de auditoria de todas as ações realizadas no WireGuard Portal.",
|
||||
"no-entries": {
|
||||
"headline": "Nenhuma entrada no registo",
|
||||
"abstract": "Atualmente, não há entradas de registo de auditoria gravadas."
|
||||
},
|
||||
"entries-headline": "Entradas do Registo",
|
||||
"table-heading": {
|
||||
"id": "#",
|
||||
"time": "Hora",
|
||||
"user": "Utilizador",
|
||||
"severity": "Gravidade",
|
||||
"origin": "Origem",
|
||||
"message": "Mensagem"
|
||||
}
|
||||
},
|
||||
"keygen": {
|
||||
"headline": "Gerador de Chaves WireGuard",
|
||||
"abstract": "Gere novas chaves WireGuard. As chaves são geradas no seu browser e nunca são enviadas para o servidor.",
|
||||
"headline-keypair": "Novo Par de Chaves",
|
||||
"headline-preshared-key": "Nova Chave Pré-Partilhada",
|
||||
"button-generate": "Gerar",
|
||||
"private-key": {
|
||||
"label": "Chave Privada",
|
||||
"placeholder": "A chave privada"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Chave Pública",
|
||||
"placeholder": "A chave pública"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "Chave Pré-Partilhada",
|
||||
"placeholder": "A chave pré-partilhada"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
"user-view": {
|
||||
"headline": "Conta de Utilizador:",
|
||||
"tab-user": "Informação",
|
||||
"tab-peers": "Peers",
|
||||
"headline-info": "Informação do Utilizador:",
|
||||
"headline-notes": "Notas:",
|
||||
"email": "E-Mail",
|
||||
"firstname": "Primeiro Nome",
|
||||
"lastname": "Último Nome",
|
||||
"phone": "Número de Telefone",
|
||||
"department": "Departamento",
|
||||
"api-enabled": "Acesso API",
|
||||
"disabled": "Conta Desativada",
|
||||
"locked": "Conta Bloqueada",
|
||||
"no-peers": "O utilizador não tem peers associados.",
|
||||
"peers": {
|
||||
"name": "Nome",
|
||||
"interface": "Interface",
|
||||
"ip": "IP's"
|
||||
}
|
||||
},
|
||||
"user-edit": {
|
||||
"headline-edit": "Editar utilizador:",
|
||||
"headline-new": "Novo utilizador",
|
||||
"header-general": "Geral",
|
||||
"header-personal": "Informação do Utilizador",
|
||||
"header-notes": "Notas",
|
||||
"header-state": "Estado",
|
||||
"identifier": {
|
||||
"label": "Identificador",
|
||||
"placeholder": "O identificador único do utilizador"
|
||||
},
|
||||
"email": {
|
||||
"label": "Email",
|
||||
"placeholder": "Introduza o email do utilizador"
|
||||
"source": {
|
||||
"label": "Fonte",
|
||||
"placeholder": "A fonte do utilizador"
|
||||
},
|
||||
"password": {
|
||||
"label": "Palavra-passe",
|
||||
"placeholder": "Deixe em branco para manter a atual"
|
||||
"placeholder": "Uma palavra-passe super secreta",
|
||||
"description": "Deixe este campo em branco para manter a palavra-passe atual."
|
||||
},
|
||||
"is-admin": {
|
||||
"label": "Administrador"
|
||||
"email": {
|
||||
"label": "Email",
|
||||
"placeholder": "O endereço de e-mail"
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Ativo"
|
||||
"phone": {
|
||||
"label": "Telefone",
|
||||
"placeholder": "O número de telefone"
|
||||
},
|
||||
"department": {
|
||||
"label": "Departamento",
|
||||
"placeholder": "O departamento"
|
||||
},
|
||||
"firstname": {
|
||||
"label": "Primeiro Nome",
|
||||
"placeholder": "Primeiro Nome"
|
||||
},
|
||||
"lastname": {
|
||||
"label": "Último Nome",
|
||||
"placeholder": "Último Nome"
|
||||
},
|
||||
"notes": {
|
||||
"label": "Notas",
|
||||
"placeholder": ""
|
||||
},
|
||||
"disabled": {
|
||||
"label": "Desativado (sem conexão WireGuard e login possível)"
|
||||
},
|
||||
"locked": {
|
||||
"label": "Bloqueado (sem login possível, as conexões WireGuard ainda funcionam)"
|
||||
},
|
||||
"admin": {
|
||||
"label": "É Administrador"
|
||||
}
|
||||
}
|
||||
},
|
||||
"peers": {
|
||||
"title": "Peers",
|
||||
"create": "Criar Peer",
|
||||
"public-key": "Chave Pública",
|
||||
"preshared-key": "Chave Pré-partilhada",
|
||||
"endpoint": "Endpoint",
|
||||
"allowed-ips": "IPs Permitidos",
|
||||
"latest-handshake": "Último Handshake",
|
||||
"transfer-rx": "Recebido",
|
||||
"transfer-tx": "Enviado",
|
||||
"persistent-keepalive": "Keepalive Persistente",
|
||||
"actions": "Ações",
|
||||
"edit": "Editar",
|
||||
"delete-dialog": {
|
||||
"title": "Eliminar Peer",
|
||||
"text": "Tem a certeza de que deseja eliminar este peer?"
|
||||
},
|
||||
"form": {
|
||||
"interface-view": {
|
||||
"headline": "Configuração para a Interface:"
|
||||
},
|
||||
"interface-edit": {
|
||||
"headline-edit": "Editar Interface:",
|
||||
"headline-new": "Nova Interface",
|
||||
"tab-interface": "Interface",
|
||||
"tab-peerdef": "Padrões de Peer",
|
||||
"header-general": "Geral",
|
||||
"header-network": "Rede",
|
||||
"header-crypto": "Criptografia",
|
||||
"header-hooks": "Hooks da Interface",
|
||||
"header-peer-hooks": "Hooks",
|
||||
"header-state": "Estado",
|
||||
"identifier": {
|
||||
"label": "Identificador",
|
||||
"placeholder": "O identificador único da interface"
|
||||
},
|
||||
"mode": {
|
||||
"label": "Modo da Interface",
|
||||
"server": "Modo Servidor",
|
||||
"client": "Modo Cliente",
|
||||
"any": "Modo Desconhecido"
|
||||
},
|
||||
"display-name": {
|
||||
"label": "Nome de Exibição",
|
||||
"placeholder": "O nome descritivo para a interface"
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Chave Privada",
|
||||
"placeholder": "A chave privada"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Chave Pública",
|
||||
"placeholder": "Introduza a chave pública do peer"
|
||||
"placeholder": "A chave pública"
|
||||
},
|
||||
"ip": {
|
||||
"label": "Endereços IP",
|
||||
"placeholder": "Endereços IP (formato CIDR)"
|
||||
},
|
||||
"listen-port": {
|
||||
"label": "Porta de Escuta",
|
||||
"placeholder": "A porta de escuta"
|
||||
},
|
||||
"dns": {
|
||||
"label": "Servidor DNS",
|
||||
"placeholder": "Os servidores DNS que devem ser usados"
|
||||
},
|
||||
"dns-search": {
|
||||
"label": "Domínios de Pesquisa DNS",
|
||||
"placeholder": "Prefixos de pesquisa DNS"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "O MTU da interface (0 = manter o valor padrão)"
|
||||
},
|
||||
"firewall-mark": {
|
||||
"label": "Marca de Firewall",
|
||||
"placeholder": "Marca de firewall aplicada ao tráfego de saída. (0 = automático)"
|
||||
},
|
||||
"routing-table": {
|
||||
"label": "Tabela de Roteamento",
|
||||
"placeholder": "O ID da tabela de roteamento",
|
||||
"description": "Casos especiais: off = não gerenciar rotas, 0 = automático"
|
||||
},
|
||||
"pre-up": {
|
||||
"label": "Pre-Up",
|
||||
"placeholder": "Um ou vários comandos bash separados por ;"
|
||||
},
|
||||
"post-up": {
|
||||
"label": "Post-Up",
|
||||
"placeholder": "Um ou vários comandos bash separados por ;"
|
||||
},
|
||||
"pre-down": {
|
||||
"label": "Pre-Down",
|
||||
"placeholder": "Um ou vários comandos bash separados por ;"
|
||||
},
|
||||
"post-down": {
|
||||
"label": "Post-Down",
|
||||
"placeholder": "Um ou vários comandos bash separados por ;"
|
||||
},
|
||||
"disabled": {
|
||||
"label": "Interface Desativada"
|
||||
},
|
||||
"save-config": {
|
||||
"label": "Guardar configuração wg-quick automaticamente"
|
||||
},
|
||||
"defaults": {
|
||||
"endpoint": {
|
||||
"label": "Endereço do Endpoint",
|
||||
"placeholder": "Endereço do Endpoint",
|
||||
"description": "O endereço do endpoint ao qual os peers se irão conectar. (ex. wg.exemplo.com ou wg.exemplo.com:51820)"
|
||||
},
|
||||
"networks": {
|
||||
"label": "Redes IP",
|
||||
"placeholder": "Endereços de Rede",
|
||||
"description": "Os peers irão obter endereços IP a partir dessas sub-redes."
|
||||
},
|
||||
"allowed-ip": {
|
||||
"label": "Endereços IP Permitidos",
|
||||
"placeholder": "Endereços IP Permitidos por padrão"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "O MTU do cliente (0 = manter o valor padrão)"
|
||||
},
|
||||
"keep-alive": {
|
||||
"label": "Intervalo de Keep Alive",
|
||||
"placeholder": "Keepalive persistente (0 = padrão)"
|
||||
}
|
||||
},
|
||||
"button-apply-defaults": "Aplicar Padrões de Peer"
|
||||
},
|
||||
"peer-view": {
|
||||
"headline-peer": "Peer:",
|
||||
"headline-endpoint": "Endpoint:",
|
||||
"section-info": "Informação do Peer",
|
||||
"section-status": "Estado Atual",
|
||||
"section-config": "Configuração",
|
||||
"identifier": "Identificador",
|
||||
"ip": "Endereços IP",
|
||||
"user": "Utilizador Associado",
|
||||
"notes": "Notas",
|
||||
"expiry-status": "Expira em",
|
||||
"disabled-status": "Desativado em",
|
||||
"traffic": "Tráfego",
|
||||
"connection-status": "Estatísticas de Conexão",
|
||||
"upload": "Bytes Enviados (do Servidor para o Peer)",
|
||||
"download": "Bytes Recebidos (do Peer para o Servidor)",
|
||||
"pingable": "É Pingável",
|
||||
"handshake": "Último Handshake",
|
||||
"connected-since": "Conectado desde",
|
||||
"endpoint": "Endpoint",
|
||||
"button-download": "Baixar configuração",
|
||||
"button-email": "Enviar configuração por E-Mail"
|
||||
},
|
||||
"peer-edit": {
|
||||
"headline-edit-peer": "Editar peer:",
|
||||
"headline-edit-endpoint": "Editar endpoint:",
|
||||
"headline-new-peer": "Criar peer",
|
||||
"headline-new-endpoint": "Criar endpoint",
|
||||
"header-general": "Geral",
|
||||
"header-network": "Rede",
|
||||
"header-crypto": "Criptografia",
|
||||
"header-hooks": "Hooks (Executados no Peer)",
|
||||
"header-state": "Estado",
|
||||
"display-name": {
|
||||
"label": "Nome de Exibição",
|
||||
"placeholder": "O nome descritivo para o peer"
|
||||
},
|
||||
"linked-user": {
|
||||
"label": "Utilizador Associado",
|
||||
"placeholder": "A conta de utilizador que possui este peer"
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Chave Privada",
|
||||
"placeholder": "A chave privada",
|
||||
"help": "A chave privada é armazenada de forma segura no servidor. Se o utilizador já tiver uma cópia, pode omitir este campo. O servidor ainda funciona exclusivamente com a chave pública do peer."
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Chave Pública",
|
||||
"placeholder": "A chave pública"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "Chave Pré-partilhada",
|
||||
"placeholder": "Opcional: Chave partilhada adicional para maior segurança"
|
||||
"label": "Chave Pré-Partilhada",
|
||||
"placeholder": "Chave pré-partilhada opcional"
|
||||
},
|
||||
"endpoint-public-key": {
|
||||
"label": "Chave Pública do Endpoint",
|
||||
"placeholder": "A chave pública do endpoint remoto"
|
||||
},
|
||||
"endpoint": {
|
||||
"label": "Endpoint",
|
||||
"placeholder": "Endereço público do peer (ex: 1.2.3.4:51820)"
|
||||
"label": "Endereço do Endpoint",
|
||||
"placeholder": "O endereço do endpoint remoto"
|
||||
},
|
||||
"allowed-ips": {
|
||||
"label": "IPs Permitidos",
|
||||
"placeholder": "Lista de IPs (ex: 10.0.0.2/32, 192.168.1.0/24)"
|
||||
"ip": {
|
||||
"label": "Endereços IP",
|
||||
"placeholder": "Endereços IP (formato CIDR)"
|
||||
},
|
||||
"persistent-keepalive": {
|
||||
"label": "Keepalive Persistente",
|
||||
"placeholder": "Ex: 25 (em segundos)"
|
||||
"allowed-ip": {
|
||||
"label": "Endereços IP Permitidos",
|
||||
"placeholder": "Endereços IP permitidos"
|
||||
},
|
||||
"extra-allowed-ip": {
|
||||
"label": "Endereços IP adicionais permitidos",
|
||||
"placeholder": "IPs adicionais permitidos (lado do servidor)",
|
||||
"description": "Esses IPs serão adicionados à interface WireGuard remota como IPs permitidos."
|
||||
},
|
||||
"dns": {
|
||||
"label": "Servidor DNS",
|
||||
"placeholder": "Os servidores DNS que devem ser utilizados"
|
||||
},
|
||||
"dns-search": {
|
||||
"label": "Domínios de Pesquisa DNS",
|
||||
"placeholder": "Prefixos de pesquisa DNS"
|
||||
},
|
||||
"keep-alive": {
|
||||
"label": "Intervalo de Keep Alive",
|
||||
"placeholder": "Keepalive persistente (0 = padrão)"
|
||||
},
|
||||
"mtu": {
|
||||
"label": "MTU",
|
||||
"placeholder": "O MTU do cliente (0 = manter o padrão)"
|
||||
},
|
||||
"pre-up": {
|
||||
"label": "Pre-Up",
|
||||
"placeholder": "Um ou vários comandos bash separados por ;"
|
||||
},
|
||||
"post-up": {
|
||||
"label": "Post-Up",
|
||||
"placeholder": "Um ou vários comandos bash separados por ;"
|
||||
},
|
||||
"pre-down": {
|
||||
"label": "Pre-Down",
|
||||
"placeholder": "Um ou vários comandos bash separados por ;"
|
||||
},
|
||||
"post-down": {
|
||||
"label": "Post-Down",
|
||||
"placeholder": "Um ou vários comandos bash separados por ;"
|
||||
},
|
||||
"disabled": {
|
||||
"label": "Peer Desativado"
|
||||
},
|
||||
"ignore-global": {
|
||||
"label": "Ignorar definições globais"
|
||||
},
|
||||
"expires-at": {
|
||||
"label": "Data de expiração"
|
||||
}
|
||||
},
|
||||
"peer-multi-create": {
|
||||
"headline-peer": "Criar múltiplos peers",
|
||||
"headline-endpoint": "Criar múltiplos endpoints",
|
||||
"identifiers": {
|
||||
"label": "Identificadores de utilizador",
|
||||
"placeholder": "Identificadores de utilizador",
|
||||
"description": "Um identificador de utilizador (nome de utilizador) para o qual um peer deve ser criado."
|
||||
},
|
||||
"prefix": {
|
||||
"headline-peer": "Peer:",
|
||||
"headline-endpoint": "Endpoint:",
|
||||
"label": "Prefixo do nome exibido",
|
||||
"placeholder": "O prefixo",
|
||||
"description": "Um prefixo que será adicionado ao nome exibido do peer."
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Definições",
|
||||
"password": {
|
||||
"label": "Nova Palavra-passe",
|
||||
"placeholder": "Deixe em branco para manter a atual"
|
||||
},
|
||||
"save": "Guardar Alterações"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Registo de Auditoria",
|
||||
"username": "Utilizador",
|
||||
"ip": "Endereço IP",
|
||||
"method": "Método",
|
||||
"path": "Caminho",
|
||||
"status": "Estado",
|
||||
"timestamp": "Data/Hora"
|
||||
},
|
||||
"errors": {
|
||||
"required": "Este campo é obrigatório",
|
||||
"invalid-email": "Endereço de email inválido",
|
||||
"invalid-address": "Endereço inválido",
|
||||
"invalid-endpoint": "Endpoint inválido",
|
||||
"invalid-allowed-ips": "Formato de IPs Permitidos inválido"
|
||||
}
|
||||
}
|
||||
|
@@ -99,7 +99,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Статус интерфейса для",
|
||||
"mode": "режим",
|
||||
"backend": "бэкэнд",
|
||||
"key": "Публичный ключ",
|
||||
"endpoint": "Публичная конечная точка",
|
||||
"port": "Порт прослушивания",
|
||||
|
@@ -99,7 +99,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Статус інтерфейсу для",
|
||||
"mode": "режим",
|
||||
"backend": "бекенд",
|
||||
"key": "Публічний ключ",
|
||||
"endpoint": "Публічна кінцева точка",
|
||||
"port": "Порт прослуховування",
|
||||
|
@@ -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",
|
||||
|
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "接口状态",
|
||||
"mode": "模式",
|
||||
"backend": "后端",
|
||||
"key": "公钥",
|
||||
"endpoint": "公开节点",
|
||||
"port": "监听端口",
|
||||
|
@@ -64,6 +64,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/AuditView.vue')
|
||||
},
|
||||
{
|
||||
path: '/key-generator',
|
||||
name: 'key-generator',
|
||||
// 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/KeyGeneraterView.vue')
|
||||
}
|
||||
],
|
||||
linkActiveClass: "active",
|
||||
@@ -114,11 +122,11 @@ router.beforeEach(async (to) => {
|
||||
}
|
||||
|
||||
// redirect to login page if not logged in and trying to access a restricted page
|
||||
const publicPages = ['/', '/login']
|
||||
const publicPages = ['/', '/login', '/key-generator']
|
||||
const authRequired = !publicPages.includes(to.path)
|
||||
|
||||
if (authRequired && !auth.IsAuthenticated) {
|
||||
auth.SetReturnUrl(to.fullPath) // store original destination before starting the auth process
|
||||
auth.SetReturnUrl(to.fullPath) // store the original destination before starting the auth process
|
||||
return '/login'
|
||||
}
|
||||
})
|
||||
|
@@ -3,13 +3,17 @@ import { defineStore } from 'pinia'
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
import { apiWrapper } from '@/helpers/fetch-wrapper'
|
||||
import router from '../router'
|
||||
import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser';
|
||||
import {base64_url_encode} from "@/helpers/encoding";
|
||||
|
||||
export const authStore = defineStore('auth',{
|
||||
state: () => ({
|
||||
// initialize state from local storage to enable user to stay logged in
|
||||
user: JSON.parse(localStorage.getItem('user')),
|
||||
providers: [],
|
||||
returnUrl: localStorage.getItem('returnUrl')
|
||||
returnUrl: localStorage.getItem('returnUrl'),
|
||||
webAuthnCredentials: [],
|
||||
fetching: false,
|
||||
}),
|
||||
getters: {
|
||||
UserIdentifier: (state) => state.user?.Identifier || 'unknown',
|
||||
@@ -18,6 +22,14 @@ export const authStore = defineStore('auth',{
|
||||
IsAuthenticated: (state) => state.user != null,
|
||||
IsAdmin: (state) => state.user?.IsAdmin || false,
|
||||
ReturnUrl: (state) => state.returnUrl || '/',
|
||||
IsWebAuthnEnabled: (state) => {
|
||||
if (state.webAuthnCredentials) {
|
||||
return state.webAuthnCredentials.length > 0
|
||||
}
|
||||
return false
|
||||
},
|
||||
WebAuthnCredentials: (state) => state.webAuthnCredentials || [],
|
||||
isFetching: (state) => state.fetching,
|
||||
},
|
||||
actions: {
|
||||
SetReturnUrl(link) {
|
||||
@@ -60,6 +72,23 @@ export const authStore = defineStore('auth',{
|
||||
return Promise.reject(err)
|
||||
})
|
||||
},
|
||||
// LoadWebAuthnCredentials returns promise that might have been rejected if the session was not authenticated.
|
||||
async LoadWebAuthnCredentials() {
|
||||
this.fetching = true
|
||||
return apiWrapper.get(`/auth/webauthn/credentials`)
|
||||
.then(credentials => {
|
||||
this.setWebAuthnCredentials(credentials)
|
||||
})
|
||||
.catch(error => {
|
||||
this.setWebAuthnCredentials([])
|
||||
console.log("Failed to load webauthn credentials:", error)
|
||||
notify({
|
||||
title: "Backend Connection Failure",
|
||||
text: error,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
},
|
||||
// Login returns promise that might have been rejected if the login attempt was not successful.
|
||||
async Login(username, password) {
|
||||
return apiWrapper.post(`/auth/login`, { username, password })
|
||||
@@ -93,6 +122,157 @@ export const authStore = defineStore('auth',{
|
||||
|
||||
await router.push('/login')
|
||||
},
|
||||
async RegisterWebAuthn() {
|
||||
// check if the browser supports WebAuthn
|
||||
if (!browserSupportsWebAuthn()) {
|
||||
console.error("WebAuthn is not supported by this browser.");
|
||||
notify({
|
||||
title: "WebAuthn not supported",
|
||||
text: "This browser does not support WebAuthn.",
|
||||
type: 'error'
|
||||
});
|
||||
return Promise.reject(new Error("WebAuthn not supported"));
|
||||
}
|
||||
|
||||
this.fetching = true
|
||||
console.log("Starting WebAuthn registration...")
|
||||
await apiWrapper.post(`/auth/webauthn/register/start`, {})
|
||||
.then(optionsJSON => {
|
||||
notify({
|
||||
title: "Passkey registration",
|
||||
text: "Starting passkey registration, follow the instructions in the browser."
|
||||
});
|
||||
console.log("Started WebAuthn registration with options: ", optionsJSON)
|
||||
|
||||
return startRegistration({ optionsJSON: optionsJSON.publicKey }).then(attResp => {
|
||||
console.log("Finishing WebAuthn registration...")
|
||||
return apiWrapper.post(`/auth/webauthn/register/finish`, attResp)
|
||||
.then(credentials => {
|
||||
console.log("Passkey registration finished successfully: ", credentials)
|
||||
this.setWebAuthnCredentials(credentials)
|
||||
notify({
|
||||
title: "Passkey registration",
|
||||
text: "A new passkey has been registered successfully!",
|
||||
type: 'success'
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
this.fetching = false
|
||||
console.error("Failed to register passkey:", err);
|
||||
notify({
|
||||
title: "Passkey registration failed",
|
||||
text: err,
|
||||
type: 'error'
|
||||
});
|
||||
})
|
||||
}).catch(err => {
|
||||
this.fetching = false
|
||||
console.error("Failed to start WebAuthn registration:", err);
|
||||
notify({
|
||||
title: "Failed to start Passkey registration",
|
||||
text: err,
|
||||
type: 'error'
|
||||
});
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
this.fetching = false
|
||||
console.error("Failed to start WebAuthn registration:", err);
|
||||
notify({
|
||||
title: "Failed to start WebAuthn registration",
|
||||
text: err,
|
||||
type: 'error'
|
||||
});
|
||||
})
|
||||
},
|
||||
async DeleteWebAuthnCredential(credentialId) {
|
||||
this.fetching = true
|
||||
return apiWrapper.delete(`/auth/webauthn/credential/${base64_url_encode(credentialId)}`)
|
||||
.then(credentials => {
|
||||
this.setWebAuthnCredentials(credentials)
|
||||
notify({
|
||||
title: "Success",
|
||||
text: "Passkey deleted successfully!",
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
this.fetching = false
|
||||
console.error("Failed to delete webauthn credential:", err);
|
||||
notify({
|
||||
title: "Backend Connection Failure",
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
},
|
||||
async RenameWebAuthnCredential(credential) {
|
||||
this.fetching = true
|
||||
return apiWrapper.put(`/auth/webauthn/credential/${base64_url_encode(credential.ID)}`, {
|
||||
Name: credential.Name,
|
||||
})
|
||||
.then(credentials => {
|
||||
this.setWebAuthnCredentials(credentials)
|
||||
notify({
|
||||
title: "Success",
|
||||
text: "Passkey renamed successfully!",
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
this.fetching = false
|
||||
console.error("Failed to rename webauthn credential", credential.ID, ":", err);
|
||||
notify({
|
||||
title: "Backend Connection Failure",
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
},
|
||||
async LoginWebAuthn() {
|
||||
// check if the browser supports WebAuthn
|
||||
if (!browserSupportsWebAuthn()) {
|
||||
console.error("WebAuthn is not supported by this browser.");
|
||||
notify({
|
||||
title: "WebAuthn not supported",
|
||||
text: "This browser does not support WebAuthn.",
|
||||
type: 'error'
|
||||
});
|
||||
return Promise.reject(new Error("WebAuthn not supported"));
|
||||
}
|
||||
|
||||
this.fetching = true
|
||||
console.log("Starting WebAuthn login...")
|
||||
await apiWrapper.post(`/auth/webauthn/login/start`, {})
|
||||
.then(optionsJSON => {
|
||||
console.log("Started WebAuthn login with options: ", optionsJSON)
|
||||
|
||||
return startAuthentication({ optionsJSON: optionsJSON.publicKey }).then(asseResp => {
|
||||
console.log("Finishing WebAuthn login ...")
|
||||
return apiWrapper.post(`/auth/webauthn/login/finish`, asseResp)
|
||||
.then(user => {
|
||||
console.log("Passkey login finished successfully for user:", user.Identifier)
|
||||
this.ResetReturnUrl()
|
||||
this.setUserInfo(user)
|
||||
return user.Identifier
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to login with passkey:", err)
|
||||
this.setUserInfo(null)
|
||||
return Promise.reject(new Error("login failed"))
|
||||
})
|
||||
}).catch(err => {
|
||||
console.error("Failed to finish passkey login:", err)
|
||||
this.setUserInfo(null)
|
||||
return Promise.reject(new Error("login failed"))
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to start passkey login:", err)
|
||||
this.setUserInfo(null)
|
||||
return Promise.reject(new Error("login failed"))
|
||||
})
|
||||
},
|
||||
// -- internal setters
|
||||
setUserInfo(userInfo) {
|
||||
// store user details and jwt in local storage to keep user logged in between page refreshes
|
||||
@@ -120,5 +300,9 @@ export const authStore = defineStore('auth',{
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
},
|
||||
setWebAuthnCredentials(credentials) {
|
||||
this.fetching = false
|
||||
this.webAuthnCredentials = credentials
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@@ -142,8 +142,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 +158,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 = ""
|
||||
|
@@ -129,7 +129,7 @@ export const profileStore = defineStore('profile', {
|
||||
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/api/enable`)
|
||||
.then(this.setUser)
|
||||
.catch(error => {
|
||||
this.setPeers([])
|
||||
this.fetching = false
|
||||
console.log("Failed to activate API for ", currentUser, ": ", error)
|
||||
notify({
|
||||
title: "Backend Connection Failure",
|
||||
@@ -143,7 +143,7 @@ export const profileStore = defineStore('profile', {
|
||||
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/api/disable`)
|
||||
.then(this.setUser)
|
||||
.catch(error => {
|
||||
this.setPeers([])
|
||||
this.fetching = false
|
||||
console.log("Failed to deactivate API for ", currentUser, ": ", error)
|
||||
notify({
|
||||
title: "Backend Connection Failure",
|
||||
|
@@ -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>
|
||||
@@ -81,7 +81,7 @@ onMounted(async () => {
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
|
||||
<div class="col-sm-6">
|
||||
<select v-model.number="audit.pageSize" class="form-select" @click="audit.afterPageSizeChange()">
|
||||
<select id="paginationSelector" v-model.number="audit.pageSize" class="form-select" @click="audit.afterPageSizeChange()">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
|
@@ -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>
|
||||
@@ -65,7 +65,7 @@ const auth = authStore()
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h4 class="card-title">{{ $t('home.about-portal.headline') }}</h4>
|
||||
<p class="card-text">{{ $t('home.about-portal.content') }}</p>
|
||||
<a href="https://github.com/h44z/wg-portal/" title="WireGuard Portal" target="_blank"
|
||||
<a href="https://wgportal.org/" title="WireGuard Portal" target="_blank"
|
||||
rel="noopener noreferrer" class="mt-auto btn btn-primary btn-sm">{{ $t('home.about-portal.button') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -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("")
|
||||
@@ -45,6 +48,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)
|
||||
|
||||
@@ -112,7 +142,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,7 +171,7 @@ 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>
|
||||
<div class="col-12 col-lg-4 text-lg-end">
|
||||
@@ -314,7 +344,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>
|
||||
@@ -370,7 +400,7 @@ 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>
|
||||
@@ -416,7 +446,7 @@ onMounted(async () => {
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
|
||||
<div class="col-sm-6">
|
||||
<select v-model.number="peers.pageSize" class="form-select" @click="peers.afterPageSizeChange()">
|
||||
<select id="paginationSelector" v-model.number="peers.pageSize" class="form-select" @click="peers.afterPageSizeChange()">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
@@ -429,3 +459,5 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style>
|
||||
</style>
|
||||
|
147
frontend/src/views/KeyGeneraterView.vue
Normal file
147
frontend/src/views/KeyGeneraterView.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup>
|
||||
|
||||
import {ref} from "vue";
|
||||
|
||||
const privateKey = ref("")
|
||||
const publicKey = ref("")
|
||||
const presharedKey = ref("")
|
||||
|
||||
/**
|
||||
* Generate an X25519 keypair using the Web Crypto API and return Base64-encoded strings.
|
||||
* @async
|
||||
* @function generateKeypair
|
||||
* @returns {Promise<{ publicKey: string, privateKey: string }>} Resolves with an object containing
|
||||
* - publicKey: the Base64-encoded public key
|
||||
* - privateKey: the Base64-encoded private key
|
||||
*/
|
||||
async function generateKeypair() {
|
||||
// 1. Generate an X25519 key pair
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'X25519', namedCurve: 'X25519' },
|
||||
true, // extractable
|
||||
['deriveBits'] // allowed usage for ECDH
|
||||
);
|
||||
|
||||
// 2. Export keys as JWK to access raw key material
|
||||
const pubJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
||||
const privJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
||||
|
||||
// 3. Convert Base64URL to standard Base64 with padding
|
||||
return {
|
||||
publicKey: b64urlToB64(pubJwk.x),
|
||||
privateKey: b64urlToB64(privJwk.d)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 32-byte pre-shared key using crypto.getRandomValues.
|
||||
* @function generatePresharedKey
|
||||
* @returns {Uint8Array} A Uint8Array of length 32 with random bytes.
|
||||
*/
|
||||
function generatePresharedKey() {
|
||||
let privateKey = new Uint8Array(32);
|
||||
window.crypto.getRandomValues(privateKey);
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Base64URL-encoded string to standard Base64 with padding.
|
||||
* @function b64urlToB64
|
||||
* @param {string} input - The Base64URL string.
|
||||
* @returns {string} The padded, standard Base64 string.
|
||||
*/
|
||||
function b64urlToB64(input) {
|
||||
let b64 = input.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (b64.length % 4) {
|
||||
b64 += '=';
|
||||
}
|
||||
return b64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an ArrayBuffer or TypedArray buffer to a Base64-encoded string.
|
||||
* @function arrayBufferToBase64
|
||||
* @param {ArrayBuffer|Uint8Array} buffer - The buffer to convert.
|
||||
* @returns {string} Base64-encoded representation of the buffer.
|
||||
*/
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; ++i) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
// Window.btoa handles binary → Base64
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new keypair and update the corresponding Vue refs.
|
||||
* @async
|
||||
* @function generateNewKeyPair
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function generateNewKeyPair() {
|
||||
const keypair = await generateKeypair();
|
||||
|
||||
privateKey.value = keypair.privateKey;
|
||||
publicKey.value = keypair.publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new pre-shared key and update the Vue ref.
|
||||
* @function generateNewPresharedKey
|
||||
*/
|
||||
function generateNewPresharedKey() {
|
||||
const rawPsk = generatePresharedKey();
|
||||
presharedKey.value = arrayBufferToBase64(rawPsk);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>{{ $t('keygen.headline') }}</h1>
|
||||
</div>
|
||||
|
||||
<p class="lead">{{ $t('keygen.abstract') }}</p>
|
||||
|
||||
<div class="mt-4 row">
|
||||
<div class="col-12 col-lg-5">
|
||||
<h1>{{ $t('keygen.headline-keypair') }}</h1>
|
||||
<fieldset>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('keygen.private-key.label') }}</label>
|
||||
<input class="form-control" v-model="privateKey" :placeholder="$t('keygen.private-key.placeholder')" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('keygen.public-key.label') }}</label>
|
||||
<input class="form-control" v-model="publicKey" :placeholder="$t('keygen.private-key.placeholder')" readonly>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<hr class="mt-4">
|
||||
<button class="btn btn-primary mb-4" type="button" @click.prevent="generateNewKeyPair">{{ $t('keygen.button-generate') }}</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="col-12 col-lg-2 mt-sm-4">
|
||||
</div>
|
||||
<div class="col-12 col-lg-5">
|
||||
<h1>{{ $t('keygen.headline-preshared-key') }}</h1>
|
||||
<fieldset>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('keygen.preshared-key.label') }}</label>
|
||||
<input class="form-control" v-model="presharedKey" :placeholder="$t('keygen.preshared-key.placeholder')" readonly>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<hr class="mt-4">
|
||||
<button class="btn btn-primary mb-4" type="button" @click.prevent="generateNewPresharedKey">{{ $t('keygen.button-generate') }}</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import {computed, ref} from "vue";
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import {authStore} from "@/stores/auth";
|
||||
import router from '../router/index.js'
|
||||
import {notify} from "@kyvg/vue3-notification";
|
||||
@@ -16,6 +16,14 @@ const password = ref("")
|
||||
const usernameInvalid = computed(() => username.value === "")
|
||||
const passwordInvalid = computed(() => password.value === "")
|
||||
const disableLoginBtn = computed(() => username.value === "" || password.value === "" || loggingIn.value)
|
||||
const showLoginForm = computed(() => {
|
||||
console.log(router.currentRoute.value.query)
|
||||
return settings.Setting('LoginFormVisible') || router.currentRoute.value.query.hasOwnProperty('all');
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await settings.LoadSettings()
|
||||
})
|
||||
|
||||
const login = async function () {
|
||||
console.log("Performing login for user:", username.value);
|
||||
@@ -28,7 +36,34 @@ const login = async function () {
|
||||
type: 'success',
|
||||
});
|
||||
loggingIn.value = false;
|
||||
settings.LoadSettings(); // only logs errors, does not throw
|
||||
settings.LoadSettings(); // reload full settings
|
||||
router.push(auth.ReturnUrl);
|
||||
})
|
||||
.catch(error => {
|
||||
notify({
|
||||
title: "Login failed!",
|
||||
text: "Authentication failed!",
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
//loggingIn.value = false;
|
||||
// delay the user from logging in for a short amount of time
|
||||
setTimeout(() => loggingIn.value = false, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
const loginWebAuthn = async function () {
|
||||
console.log("Performing webauthn login");
|
||||
loggingIn.value = true;
|
||||
auth.LoginWebAuthn()
|
||||
.then(uid => {
|
||||
notify({
|
||||
title: "Logged in",
|
||||
text: "Authentication succeeded!",
|
||||
type: 'success',
|
||||
});
|
||||
loggingIn.value = false;
|
||||
settings.LoadSettings(); // reload full settings
|
||||
router.push(auth.ReturnUrl);
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -66,7 +101,7 @@ const externalLogin = function (provider) {
|
||||
</div></div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<fieldset>
|
||||
<fieldset v-if="showLoginForm">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="inputUsername">{{ $t('login.username.label') }}</label>
|
||||
<div class="input-group mb-3">
|
||||
@@ -85,17 +120,46 @@ const externalLogin = function (provider) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-5 d-flex">
|
||||
<div :class="{'col-lg-4':auth.LoginProviders.length < 3, 'col-lg-12':auth.LoginProviders.length >= 3}" class="d-flex mb-2">
|
||||
<button :disabled="disableLoginBtn" class="btn btn-primary flex-fill" type="submit" @click.prevent="login">
|
||||
<div class="row mt-5 mb-2">
|
||||
<div class="col-sm-4 col-xs-12">
|
||||
<button :disabled="disableLoginBtn" class="btn btn-primary mb-2" type="submit" @click.prevent="login">
|
||||
{{ $t('login.button') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="{'col-lg-8':auth.LoginProviders.length < 3, 'col-lg-12':auth.LoginProviders.length >= 3}" class="d-flex mb-2">
|
||||
<div class="col-sm-8 col-xs-12 text-sm-end">
|
||||
<button v-if="settings.Setting('WebAuthnEnabled')" class="btn btn-primary" type="submit" @click.prevent="loginWebAuthn">
|
||||
{{ $t('login.button-webauthn') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4 d-flex">
|
||||
<div class="col-lg-12 d-flex mb-2">
|
||||
<!-- OpenIdConnect / OAUTH providers -->
|
||||
<button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}"
|
||||
:disabled="loggingIn" :title="provider.Name" class="btn btn-outline-primary flex-fill"
|
||||
v-html="provider.Name" @click.prevent="externalLogin(provider)"></button>
|
||||
<button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}"
|
||||
:disabled="loggingIn" :title="provider.Name" class="btn btn-outline-primary flex-fill"
|
||||
v-html="provider.Name" @click.prevent="externalLogin(provider)"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset v-else>
|
||||
<div class="row mt-1 mb-2" v-if="settings.Setting('WebAuthnEnabled')">
|
||||
<div class="col-lg-12 d-flex mb-2">
|
||||
<button class="btn btn-outline-primary flex-fill" type="submit" @click.prevent="loginWebAuthn">
|
||||
{{ $t('login.button-webauthn') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-1 d-flex">
|
||||
<div class="col-lg-12 d-flex mb-2">
|
||||
<!-- OpenIdConnect / OAUTH providers -->
|
||||
<button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}"
|
||||
:disabled="loggingIn" :title="provider.Name" class="btn btn-outline-primary flex-fill"
|
||||
v-html="provider.Name" @click.prevent="externalLogin(provider)"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +168,6 @@ const externalLogin = function (provider) {
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -65,7 +65,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 +73,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">
|
||||
@@ -178,7 +178,7 @@ onMounted(async () => {
|
||||
{{ $t('general.pagination.size')}}:
|
||||
</label>
|
||||
<div class="col-sm-6">
|
||||
<select v-model.number="profile.pageSize" class="form-select" @click="profile.afterPageSizeChange()">
|
||||
<select id="paginationSelector" v-model.number="profile.pageSize" class="form-select" @click="profile.afterPageSizeChange()">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
import {onMounted, ref} from "vue";
|
||||
import { profileStore } from "@/stores/profile";
|
||||
import { settingsStore } from "@/stores/settings";
|
||||
import { authStore } from "../stores/auth";
|
||||
@@ -10,8 +10,30 @@ const auth = authStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await profile.LoadUser()
|
||||
await auth.LoadWebAuthnCredentials()
|
||||
})
|
||||
|
||||
const selectedCredential = ref({})
|
||||
|
||||
function enableRename(credential) {
|
||||
credential.renameMode = true;
|
||||
credential.tempName = credential.Name; // Store the original name
|
||||
}
|
||||
|
||||
function cancelRename(credential) {
|
||||
credential.renameMode = false;
|
||||
credential.tempName = null; // Discard changes
|
||||
}
|
||||
|
||||
async function saveRename(credential) {
|
||||
try {
|
||||
await auth.RenameWebAuthnCredential({ ...credential, Name: credential.tempName });
|
||||
credential.Name = credential.tempName; // Update the name
|
||||
credential.renameMode = false;
|
||||
} catch (error) {
|
||||
console.error("Failed to rename credential:", error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -22,7 +44,7 @@ onMounted(async () => {
|
||||
<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">
|
||||
<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">
|
||||
@@ -50,7 +72,7 @@ onMounted(async () => {
|
||||
</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">
|
||||
<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>
|
||||
@@ -59,14 +81,96 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-light p-5" v-else>
|
||||
<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="input-group-text btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<p v-if="auth.IsWebAuthnEnabled">{{ $t('settings.webauthn.active-description') }}</p>
|
||||
<p v-else>{{ $t('settings.webauthn.inactive-description') }}</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.WebAuthnCredentials.length > 0" class="mt-4">
|
||||
<h3>{{ $t('settings.webauthn.credentials-list') }}</h3>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%">{{ $t('settings.webauthn.table.name') }}</th>
|
||||
<th style="width: 20%">{{ $t('settings.webauthn.table.created') }}</th>
|
||||
<th style="width: 30%">{{ $t('settings.webauthn.table.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="credential in auth.webAuthnCredentials" :key="credential.ID">
|
||||
<td class="align-middle">
|
||||
<div v-if="credential.renameMode">
|
||||
<input v-model="credential.tempName" class="form-control" type="text" />
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ credential.Name }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{{ credential.CreatedAt }}
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
<div v-if="credential.renameMode">
|
||||
<button class="btn btn-success me-1" :title="$t('settings.webauthn.button-save-text')" @click.prevent="saveRename(credential)" :disabled="auth.isFetching">
|
||||
{{ $t('settings.webauthn.button-save-title') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" :title="$t('settings.webauthn.button-cancel-text')" @click.prevent="cancelRename(credential)">
|
||||
{{ $t('settings.webauthn.button-cancel-title') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<button class="btn btn-secondary me-1" :title="$t('settings.webauthn.button-rename-text')" @click.prevent="enableRename(credential)">
|
||||
{{ $t('settings.webauthn.button-rename-title') }}
|
||||
</button>
|
||||
<button class="btn btn-danger" :title="$t('settings.webauthn.button-delete-text')" data-bs-toggle="modal" data-bs-target="#webAuthnDeleteModal" :disabled="auth.isFetching" @click="selectedCredential=credential">
|
||||
{{ $t('settings.webauthn.button-delete-title') }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="webAuthnDeleteModal" tabindex="-1" aria-labelledby="webAuthnDeleteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="webAuthnDeleteModalLabel">{{ $t('settings.webauthn.modal-delete.headline') }}</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" :aria-label="$t('settings.webauthn.modal-delete.button-cancel')"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5 class="mb-3">{{ selectedCredential.Name }} <small class="text-body-secondary">({{ $t('settings.webauthn.modal-delete.created') }} {{ selectedCredential.CreatedAt }})</small></h5>
|
||||
<p class="mb-0">{{ $t('settings.webauthn.modal-delete.abstract') }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ $t('settings.webauthn.modal-delete.button-cancel') }}</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmWebAuthnDelete" @click="auth.DeleteWebAuthnCredential(selectedCredential.ID)" :disabled="auth.isFetching" data-bs-dismiss="modal">{{ $t('settings.webauthn.modal-delete.button-delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
@@ -35,7 +35,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>
|
||||
@@ -116,7 +116,7 @@ onMounted(() => {
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
|
||||
<div class="col-sm-6">
|
||||
<select v-model.number="users.pageSize" class="form-select" @click="users.afterPageSizeChange()">
|
||||
<select id="paginationSelector" v-model.number="users.pageSize" class="form-select" @click="users.afterPageSizeChange()">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
|
98
go.mod
98
go.mod
@@ -4,31 +4,32 @@ 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.16.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-ldap/ldap/v3 v3.4.12
|
||||
github.com/go-pkgz/routegroup v1.5.3
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
github.com/go-webauthn/webauthn v0.14.0
|
||||
github.com/google/uuid v1.6.0
|
||||
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.0
|
||||
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.37.0
|
||||
golang.org/x/oauth2 v0.29.0
|
||||
golang.org/x/sys v0.32.0
|
||||
golang.org/x/crypto v0.42.0
|
||||
golang.org/x/oauth2 v0.31.0
|
||||
golang.org/x/sys v0.36.0
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/driver/sqlserver v1.5.4
|
||||
gorm.io/gorm v1.25.12
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlserver v1.6.1
|
||||
gorm.io/gorm v1.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -39,57 +40,68 @@ require (
|
||||
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/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // 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.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||
github.com/go-openapi/spec v0.22.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 // 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.25 // 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.6 // 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/klauspost/compress v1.18.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/microsoft/go-mssqldb v1.9.3 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // 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.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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.39.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/tools v0.32.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-20251002181428-27f1f14c8bb9 // indirect
|
||||
golang.org/x/mod v0.28.0 // indirect
|
||||
golang.org/x/net v0.44.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
modernc.org/libc v1.66.10 // 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.39.0 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
339
go.sum
339
go.sum
@@ -2,41 +2,45 @@ 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.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.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA=
|
||||
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.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.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.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.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.1/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo=
|
||||
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/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.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 v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
|
||||
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.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
||||
github.com/coreos/go-oidc/v3 v3.16.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=
|
||||
@@ -44,68 +48,78 @@ 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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
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.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/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.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
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.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
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.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
|
||||
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
|
||||
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.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
|
||||
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
||||
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
|
||||
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
||||
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
||||
github.com/go-pkgz/routegroup v1.5.3 h1:IvH1KLcQkMap9jucQGBlef3IBloxSAe8USUFvxShFqs=
|
||||
github.com/go-pkgz/routegroup v1.5.3/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/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.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
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.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
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.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
|
||||
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
|
||||
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
|
||||
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.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/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
|
||||
github.com/google/go-tpm v0.9.6/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.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.5.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=
|
||||
@@ -117,10 +131,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/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.3 h1:PO1wNKj/bTAwxSJnO1Z4Ai8j4magtqg2SLNjEDzcXQo=
|
||||
github.com/jackc/pgx/v5 v5.7.3/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
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=
|
||||
@@ -139,35 +151,34 @@ 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 v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
||||
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.3 h1:hy4p+LDC8LIGvI3JATnLVmBOLMJbmn5X400mr5j0lPs=
|
||||
github.com/microsoft/go-mssqldb v1.9.3/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
|
||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
|
||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/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.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -177,31 +188,28 @@ github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
|
||||
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.6.1 h1:EQukUOma9YFZRPe4DGSscxUf9LH07rpqwisNWjSZrgU=
|
||||
github.com/prometheus-community/pro-bing v0.6.1/go.mod h1:jNCOI3D7pmTCeaoF41cNS6uaxeFY/Gmc3ffwbuJVzAQ=
|
||||
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.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
||||
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_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
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.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/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/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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -209,20 +217,22 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
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.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.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=
|
||||
github.com/vardius/message-bus v1.1.5 h1:YSAC2WB4HRlwc4neFPTmT88kzzoiQ+9WRRbej/E/LZc=
|
||||
github.com/vardius/message-bus v1.1.5/go.mod h1:6xladCV2lMkUAE4bzzS85qKOiB5miV7aBVRafiTJGqw=
|
||||
github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk=
|
||||
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||
@@ -232,32 +242,38 @@ 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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.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.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
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.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
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.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
|
||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
|
||||
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.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -269,27 +285,25 @@ 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.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
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.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
|
||||
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
||||
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -304,14 +318,14 @@ 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.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.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=
|
||||
@@ -321,12 +335,12 @@ 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.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||
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.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -338,28 +352,25 @@ 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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-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.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
||||
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
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.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
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=
|
||||
@@ -370,48 +381,40 @@ 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g=
|
||||
gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g=
|
||||
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
|
||||
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
|
||||
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
|
||||
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
|
||||
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
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.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY=
|
||||
gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
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/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/libc v1.63.0 h1:wKzb61wOGCzgahQBORb1b0dZonh8Ufzl/7r4Yf1D5YA=
|
||||
modernc.org/libc v1.63.0/go.mod h1:wDzH1mgz1wUIEwottFt++POjGRO9sgyQKrpXaz3x89E=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
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.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
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.36.1 h1:bDa8BJUH4lg6EGkLbahKe/8QqoF8p9gArSc6fTqYhyQ=
|
||||
modernc.org/sqlite v1.36.1/go.mod h1:7MPwH7Z6bREicF9ZVUR78P1IKuxfZ8mRIDHD0iD+8TU=
|
||||
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.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
||||
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
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=
|
||||
|
@@ -220,6 +220,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 webauthn credentials", "result",
|
||||
r.db.AutoMigrate(&domain.UserWebauthnCredential{}))
|
||||
slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{}))
|
||||
slog.Debug("running migration: peer", "result", r.db.AutoMigrate(&domain.Peer{}))
|
||||
slog.Debug("running migration: peer status", "result", r.db.AutoMigrate(&domain.PeerStatus{}))
|
||||
@@ -746,7 +748,7 @@ func (r *SqlRepo) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr
|
||||
func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
|
||||
var user domain.User
|
||||
|
||||
err := r.db.WithContext(ctx).First(&user, id).Error
|
||||
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").First(&user, id).Error
|
||||
|
||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, domain.ErrNotFound
|
||||
@@ -764,7 +766,7 @@ func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domai
|
||||
func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
var users []domain.User
|
||||
|
||||
err := r.db.WithContext(ctx).Where("email = ?", email).Find(&users).Error
|
||||
err := r.db.WithContext(ctx).Where("email = ?", email).Preload("WebAuthnCredentialList").Find(&users).Error
|
||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
@@ -785,11 +787,26 @@ func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.Use
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByWebAuthnCredential returns the user with the given webauthn credential id.
|
||||
func (r *SqlRepo) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) {
|
||||
var credential domain.UserWebauthnCredential
|
||||
|
||||
err := r.db.WithContext(ctx).Where("credential_identifier = ?", credentialIdBase64).First(&credential).Error
|
||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.GetUser(ctx, domain.UserIdentifier(credential.UserIdentifier))
|
||||
}
|
||||
|
||||
// GetAllUsers returns all users.
|
||||
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
||||
var users []domain.User
|
||||
|
||||
err := r.db.WithContext(ctx).Find(&users).Error
|
||||
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Find(&users).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -808,6 +825,7 @@ func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User,
|
||||
Or("firstname LIKE ?", searchValue).
|
||||
Or("lastname LIKE ?", searchValue).
|
||||
Or("email LIKE ?", searchValue).
|
||||
Preload("WebAuthnCredentialList").
|
||||
Find(&users).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -853,7 +871,7 @@ func (r *SqlRepo) SaveUser(
|
||||
|
||||
// DeleteUser deletes the user with the given id.
|
||||
func (r *SqlRepo) DeleteUser(ctx context.Context, id domain.UserIdentifier) error {
|
||||
err := r.db.WithContext(ctx).Delete(&domain.User{}, id).Error
|
||||
err := r.db.WithContext(ctx).Unscoped().Select(clause.Associations).Delete(&domain.User{Identifier: id}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -897,6 +915,11 @@ func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *doma
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Session(&gorm.Session{FullSaveAssociations: true}).Unscoped().Model(user).Association("WebAuthnCredentialList").Unscoped().Replace(user.WebAuthnCredentialList)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update users webauthn credentials: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@@ -133,5 +133,5 @@ func (m *MetricsServer) UpdatePeerMetrics(peer *domain.Peer, status domain.PeerS
|
||||
}
|
||||
m.peerReceivedBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesReceived))
|
||||
m.peerSendBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesTransmitted))
|
||||
m.peerIsConnected.WithLabelValues(labels...).Set(internal.BoolToFloat64(status.IsConnected()))
|
||||
m.peerIsConnected.WithLabelValues(labels...).Set(internal.BoolToFloat64(status.IsConnected))
|
||||
}
|
||||
|
864
internal/adapters/wgcontroller/local.go
Normal file
864
internal/adapters/wgcontroller/local.go
Normal file
@@ -0,0 +1,864 @@
|
||||
package wgcontroller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
probing "github.com/prometheus-community/pro-bing"
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.org/x/sys/unix"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
"github.com/h44z/wg-portal/internal/lowlevel"
|
||||
)
|
||||
|
||||
// region dependencies
|
||||
|
||||
// WgCtrlRepo is used to control local WireGuard devices via the wgctrl-go library.
|
||||
type WgCtrlRepo interface {
|
||||
io.Closer
|
||||
Devices() ([]*wgtypes.Device, error)
|
||||
Device(name string) (*wgtypes.Device, error)
|
||||
ConfigureDevice(name string, cfg wgtypes.Config) error
|
||||
}
|
||||
|
||||
// A NetlinkClient is a type which can control a netlink device.
|
||||
type NetlinkClient interface {
|
||||
LinkAdd(link netlink.Link) error
|
||||
LinkDel(link netlink.Link) error
|
||||
LinkByName(name string) (netlink.Link, error)
|
||||
LinkSetUp(link netlink.Link) error
|
||||
LinkSetDown(link netlink.Link) error
|
||||
LinkSetMTU(link netlink.Link, mtu int) error
|
||||
AddrReplace(link netlink.Link, addr *netlink.Addr) error
|
||||
AddrAdd(link netlink.Link, addr *netlink.Addr) error
|
||||
AddrList(link netlink.Link) ([]netlink.Addr, error)
|
||||
AddrDel(link netlink.Link, addr *netlink.Addr) error
|
||||
RouteAdd(route *netlink.Route) error
|
||||
RouteDel(route *netlink.Route) error
|
||||
RouteReplace(route *netlink.Route) error
|
||||
RouteList(link netlink.Link, family int) ([]netlink.Route, error)
|
||||
RouteListFiltered(family int, filter *netlink.Route, filterMask uint64) ([]netlink.Route, error)
|
||||
RuleAdd(rule *netlink.Rule) error
|
||||
RuleDel(rule *netlink.Rule) error
|
||||
RuleList(family int) ([]netlink.Rule, error)
|
||||
}
|
||||
|
||||
// endregion dependencies
|
||||
|
||||
type LocalController struct {
|
||||
cfg *config.Config
|
||||
|
||||
wg WgCtrlRepo
|
||||
nl NetlinkClient
|
||||
|
||||
shellCmd string
|
||||
resolvConfIfacePrefix string
|
||||
}
|
||||
|
||||
// NewLocalController creates a new local controller instance.
|
||||
// This repository is used to interact with the WireGuard kernel or userspace module.
|
||||
func NewLocalController(cfg *config.Config) (*LocalController, error) {
|
||||
wg, err := wgctrl.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create wgctrl client: %w", err)
|
||||
}
|
||||
|
||||
nl := &lowlevel.NetlinkManager{}
|
||||
|
||||
repo := &LocalController{
|
||||
cfg: cfg,
|
||||
|
||||
wg: wg,
|
||||
nl: nl,
|
||||
|
||||
shellCmd: "bash", // we only support bash at the moment
|
||||
resolvConfIfacePrefix: "tun.", // WireGuard interfaces have a tun. prefix in resolvconf
|
||||
}
|
||||
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
func (c LocalController) GetId() domain.InterfaceBackend {
|
||||
return config.LocalBackendName
|
||||
}
|
||||
|
||||
// region wireguard-related
|
||||
|
||||
func (c LocalController) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) {
|
||||
devices, err := c.wg.Devices()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device list error: %w", err)
|
||||
}
|
||||
|
||||
interfaces := make([]domain.PhysicalInterface, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
interfaceModel, err := c.convertWireGuardInterface(device)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("interface convert failed for %s: %w", device.Name, err)
|
||||
}
|
||||
interfaces = append(interfaces, interfaceModel)
|
||||
}
|
||||
|
||||
return interfaces, nil
|
||||
}
|
||||
|
||||
func (c LocalController) GetInterface(_ context.Context, id domain.InterfaceIdentifier) (
|
||||
*domain.PhysicalInterface,
|
||||
error,
|
||||
) {
|
||||
return c.getInterface(id)
|
||||
}
|
||||
|
||||
func (c LocalController) convertWireGuardInterface(device *wgtypes.Device) (domain.PhysicalInterface, error) {
|
||||
// read data from wgctrl interface
|
||||
|
||||
iface := domain.PhysicalInterface{
|
||||
Identifier: domain.InterfaceIdentifier(device.Name),
|
||||
KeyPair: domain.KeyPair{
|
||||
PrivateKey: device.PrivateKey.String(),
|
||||
PublicKey: device.PublicKey.String(),
|
||||
},
|
||||
ListenPort: device.ListenPort,
|
||||
Addresses: nil,
|
||||
Mtu: 0,
|
||||
FirewallMark: uint32(device.FirewallMark),
|
||||
DeviceUp: false,
|
||||
ImportSource: domain.ControllerTypeLocal,
|
||||
DeviceType: device.Type.String(),
|
||||
BytesUpload: 0,
|
||||
BytesDownload: 0,
|
||||
}
|
||||
|
||||
// read data from netlink interface
|
||||
|
||||
lowLevelInterface, err := c.nl.LinkByName(device.Name)
|
||||
if err != nil {
|
||||
return domain.PhysicalInterface{}, fmt.Errorf("netlink error for %s: %w", device.Name, err)
|
||||
}
|
||||
ipAddresses, err := c.nl.AddrList(lowLevelInterface)
|
||||
if err != nil {
|
||||
return domain.PhysicalInterface{}, fmt.Errorf("ip read error for %s: %w", device.Name, err)
|
||||
}
|
||||
|
||||
for _, addr := range ipAddresses {
|
||||
iface.Addresses = append(iface.Addresses, domain.CidrFromNetlinkAddr(addr))
|
||||
}
|
||||
iface.Mtu = lowLevelInterface.Attrs().MTU
|
||||
iface.DeviceUp = lowLevelInterface.Attrs().OperState == netlink.OperUnknown // wg only supports unknown
|
||||
if stats := lowLevelInterface.Attrs().Statistics; stats != nil {
|
||||
iface.BytesUpload = stats.TxBytes
|
||||
iface.BytesDownload = stats.RxBytes
|
||||
}
|
||||
|
||||
return iface, nil
|
||||
}
|
||||
|
||||
func (c LocalController) GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) (
|
||||
[]domain.PhysicalPeer,
|
||||
error,
|
||||
) {
|
||||
device, err := c.wg.Device(string(deviceId))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device error: %w", err)
|
||||
}
|
||||
|
||||
peers := make([]domain.PhysicalPeer, 0, len(device.Peers))
|
||||
for _, peer := range device.Peers {
|
||||
peerModel, err := c.convertWireGuardPeer(&peer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peer convert failed for %v: %w", peer.PublicKey, err)
|
||||
}
|
||||
peers = append(peers, peerModel)
|
||||
}
|
||||
|
||||
return peers, nil
|
||||
}
|
||||
|
||||
func (c LocalController) convertWireGuardPeer(peer *wgtypes.Peer) (domain.PhysicalPeer, error) {
|
||||
peerModel := domain.PhysicalPeer{
|
||||
Identifier: domain.PeerIdentifier(peer.PublicKey.String()),
|
||||
Endpoint: "",
|
||||
AllowedIPs: nil,
|
||||
KeyPair: domain.KeyPair{
|
||||
PublicKey: peer.PublicKey.String(),
|
||||
},
|
||||
PresharedKey: "",
|
||||
PersistentKeepalive: int(peer.PersistentKeepaliveInterval.Seconds()),
|
||||
LastHandshake: peer.LastHandshakeTime,
|
||||
ProtocolVersion: peer.ProtocolVersion,
|
||||
BytesUpload: uint64(peer.ReceiveBytes),
|
||||
BytesDownload: uint64(peer.TransmitBytes),
|
||||
ImportSource: domain.ControllerTypeLocal,
|
||||
}
|
||||
|
||||
// Set local extras - local peers are never disabled in the kernel
|
||||
peerModel.SetExtras(domain.LocalPeerExtras{
|
||||
Disabled: false,
|
||||
})
|
||||
|
||||
for _, addr := range peer.AllowedIPs {
|
||||
peerModel.AllowedIPs = append(peerModel.AllowedIPs, domain.CidrFromIpNet(addr))
|
||||
}
|
||||
if peer.Endpoint != nil {
|
||||
peerModel.Endpoint = peer.Endpoint.String()
|
||||
}
|
||||
if peer.PresharedKey != (wgtypes.Key{}) {
|
||||
peerModel.PresharedKey = domain.PreSharedKey(peer.PresharedKey.String())
|
||||
}
|
||||
|
||||
return peerModel, nil
|
||||
}
|
||||
|
||||
func (c LocalController) SaveInterface(
|
||||
_ context.Context,
|
||||
id domain.InterfaceIdentifier,
|
||||
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
|
||||
) error {
|
||||
physicalInterface, err := c.getOrCreateInterface(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if updateFunc != nil {
|
||||
physicalInterface, err = updateFunc(physicalInterface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.updateLowLevelInterface(physicalInterface); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.updateWireGuardInterface(physicalInterface); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) getOrCreateInterface(id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) {
|
||||
device, err := c.getInterface(id)
|
||||
if err == nil {
|
||||
return device, nil // interface exists
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("device error: %w", err) // unknown error
|
||||
}
|
||||
|
||||
// create new device
|
||||
if err := c.createLowLevelInterface(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
device, err = c.getInterface(id)
|
||||
return device, err
|
||||
}
|
||||
|
||||
func (c LocalController) getInterface(id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) {
|
||||
device, err := c.wg.Device(string(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pi, err := c.convertWireGuardInterface(device)
|
||||
return &pi, err
|
||||
}
|
||||
|
||||
func (c LocalController) createLowLevelInterface(id domain.InterfaceIdentifier) error {
|
||||
link := &netlink.GenericLink{
|
||||
LinkAttrs: netlink.LinkAttrs{
|
||||
Name: string(id),
|
||||
},
|
||||
LinkType: "wireguard",
|
||||
}
|
||||
err := c.nl.LinkAdd(link)
|
||||
if err != nil {
|
||||
return fmt.Errorf("link add failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) updateLowLevelInterface(pi *domain.PhysicalInterface) error {
|
||||
link, err := c.nl.LinkByName(string(pi.Identifier))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pi.Mtu != 0 {
|
||||
if err := c.nl.LinkSetMTU(link, pi.Mtu); err != nil {
|
||||
return fmt.Errorf("mtu error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, addr := range pi.Addresses {
|
||||
err := c.nl.AddrReplace(link, addr.NetlinkAddr())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set ip %s: %w", addr.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove unwanted IP addresses
|
||||
rawAddresses, err := c.nl.AddrList(link)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch interface ips: %w", err)
|
||||
}
|
||||
for _, rawAddr := range rawAddresses {
|
||||
netlinkAddr := domain.CidrFromNetlinkAddr(rawAddr)
|
||||
remove := true
|
||||
for _, addr := range pi.Addresses {
|
||||
if addr == netlinkAddr {
|
||||
remove = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !remove {
|
||||
continue
|
||||
}
|
||||
|
||||
err := c.nl.AddrDel(link, &rawAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove deprecated ip %s: %w", netlinkAddr.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update link state
|
||||
if pi.DeviceUp {
|
||||
if err := c.nl.LinkSetUp(link); err != nil {
|
||||
return fmt.Errorf("failed to bring up device: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := c.nl.LinkSetDown(link); err != nil {
|
||||
return fmt.Errorf("failed to bring down device: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) updateWireGuardInterface(pi *domain.PhysicalInterface) error {
|
||||
pKey, err := wgtypes.NewKey(pi.KeyPair.GetPrivateKeyBytes())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var fwMark *int
|
||||
if pi.FirewallMark != 0 {
|
||||
intFwMark := int(pi.FirewallMark)
|
||||
fwMark = &intFwMark
|
||||
}
|
||||
err = c.wg.ConfigureDevice(string(pi.Identifier), wgtypes.Config{
|
||||
PrivateKey: &pKey,
|
||||
ListenPort: &pi.ListenPort,
|
||||
FirewallMark: fwMark,
|
||||
ReplacePeers: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error {
|
||||
if err := c.deleteLowLevelInterface(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) deleteLowLevelInterface(id domain.InterfaceIdentifier) error {
|
||||
link, err := c.nl.LinkByName(string(id))
|
||||
if err != nil {
|
||||
var linkNotFoundError netlink.LinkNotFoundError
|
||||
if errors.As(err, &linkNotFoundError) {
|
||||
return nil // ignore not found error
|
||||
}
|
||||
return fmt.Errorf("unable to find low level interface: %w", err)
|
||||
}
|
||||
|
||||
err = c.nl.LinkDel(link)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete low level interface: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) SavePeer(
|
||||
_ context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
|
||||
) error {
|
||||
physicalPeer, err := c.getOrCreatePeer(deviceId, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
physicalPeer, err = updateFunc(physicalPeer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if the peer is disabled by looking at the backend extras
|
||||
// For local controller, disabled peers should be deleted
|
||||
if physicalPeer.GetExtras() != nil {
|
||||
switch extras := physicalPeer.GetExtras().(type) {
|
||||
case domain.LocalPeerExtras:
|
||||
if extras.Disabled {
|
||||
// Delete the peer instead of updating it
|
||||
return c.deletePeer(deviceId, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.updatePeer(deviceId, physicalPeer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) getOrCreatePeer(deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) (
|
||||
*domain.PhysicalPeer,
|
||||
error,
|
||||
) {
|
||||
peer, err := c.getPeer(deviceId, id)
|
||||
if err == nil {
|
||||
return peer, nil // peer exists
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("peer error: %w", err) // unknown error
|
||||
}
|
||||
|
||||
// create new peer
|
||||
err = c.wg.ConfigureDevice(string(deviceId), wgtypes.Config{
|
||||
Peers: []wgtypes.PeerConfig{
|
||||
{
|
||||
PublicKey: id.ToPublicKey(),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peer create error for %s: %w", id.ToPublicKey(), err)
|
||||
}
|
||||
|
||||
peer, err = c.getPeer(deviceId, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peer error after create: %w", err)
|
||||
}
|
||||
return peer, nil
|
||||
}
|
||||
|
||||
func (c LocalController) getPeer(deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) (
|
||||
*domain.PhysicalPeer,
|
||||
error,
|
||||
) {
|
||||
if !id.IsPublicKey() {
|
||||
return nil, errors.New("invalid public key")
|
||||
}
|
||||
|
||||
device, err := c.wg.Device(string(deviceId))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publicKey := id.ToPublicKey()
|
||||
for _, peer := range device.Peers {
|
||||
if peer.PublicKey != publicKey {
|
||||
continue
|
||||
}
|
||||
|
||||
peerModel, err := c.convertWireGuardPeer(&peer)
|
||||
return &peerModel, err
|
||||
}
|
||||
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
func (c LocalController) updatePeer(deviceId domain.InterfaceIdentifier, pp *domain.PhysicalPeer) error {
|
||||
cfg := wgtypes.PeerConfig{
|
||||
PublicKey: pp.GetPublicKey(),
|
||||
Remove: false,
|
||||
UpdateOnly: true,
|
||||
PresharedKey: pp.GetPresharedKey(),
|
||||
Endpoint: pp.GetEndpointAddress(),
|
||||
PersistentKeepaliveInterval: pp.GetPersistentKeepaliveTime(),
|
||||
ReplaceAllowedIPs: true,
|
||||
AllowedIPs: pp.GetAllowedIPs(),
|
||||
}
|
||||
|
||||
err := c.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) DeletePeer(
|
||||
_ context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
) error {
|
||||
if !id.IsPublicKey() {
|
||||
return errors.New("invalid public key")
|
||||
}
|
||||
|
||||
err := c.deletePeer(deviceId, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) deletePeer(deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error {
|
||||
cfg := wgtypes.PeerConfig{
|
||||
PublicKey: id.ToPublicKey(),
|
||||
Remove: true,
|
||||
}
|
||||
|
||||
err := c.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// endregion wireguard-related
|
||||
|
||||
// region wg-quick-related
|
||||
|
||||
func (c LocalController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error {
|
||||
if hookCmd == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Debug("executing interface hook", "interface", id, "hook", hookCmd)
|
||||
err := c.exec(hookCmd, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to exec hook: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) 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 := c.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
|
||||
}
|
||||
|
||||
func (c LocalController) UnsetDNS(id domain.InterfaceIdentifier) error {
|
||||
dnsCommand := "resolvconf -d %resPref%i -f"
|
||||
|
||||
err := c.exec(dnsCommand, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unset dns settings: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) replaceCommandPlaceHolders(command string, interfaceId domain.InterfaceIdentifier) string {
|
||||
command = strings.ReplaceAll(command, "%resPref", c.resolvConfIfacePrefix)
|
||||
return strings.ReplaceAll(command, "%i", string(interfaceId))
|
||||
}
|
||||
|
||||
func (c LocalController) exec(command string, interfaceId domain.InterfaceIdentifier, stdin ...string) error {
|
||||
commandWithInterfaceName := c.replaceCommandPlaceHolders(command, interfaceId)
|
||||
cmd := exec.Command(c.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
|
||||
}
|
||||
|
||||
// endregion wg-quick-related
|
||||
|
||||
// region routing-related
|
||||
|
||||
func (c LocalController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error {
|
||||
// update fwmark rules
|
||||
if err := c.setFwMarkRules(rules); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update main rule
|
||||
if err := c.setMainRule(rules); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// cleanup old main rules
|
||||
if err := c.cleanupMainRule(rules); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) setFwMarkRules(rules []domain.RouteRule) error {
|
||||
for _, rule := range rules {
|
||||
existingRules, err := c.nl.RuleList(int(rule.IpFamily))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing rules for family %s: %w", rule.IpFamily, err)
|
||||
}
|
||||
|
||||
ruleExists := false
|
||||
for _, existingRule := range existingRules {
|
||||
if rule.FwMark == existingRule.Mark && rule.Table == existingRule.Table {
|
||||
ruleExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ruleExists {
|
||||
continue // rule already exists, no need to recreate it
|
||||
}
|
||||
|
||||
// create a missing rule
|
||||
if err := c.nl.RuleAdd(&netlink.Rule{
|
||||
Family: int(rule.IpFamily),
|
||||
Table: rule.Table,
|
||||
Mark: rule.FwMark,
|
||||
Invert: true,
|
||||
SuppressIfgroup: -1,
|
||||
SuppressPrefixlen: -1,
|
||||
Priority: c.getRulePriority(existingRules),
|
||||
Mask: nil,
|
||||
Goto: -1,
|
||||
Flow: -1,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to setup %s rule for fwmark %d and table %d: %w",
|
||||
rule.IpFamily, rule.FwMark, rule.Table, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) getRulePriority(existingRules []netlink.Rule) int {
|
||||
prio := 32700 // linux main rule has a priority of 32766
|
||||
for {
|
||||
isFresh := true
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Priority == prio {
|
||||
isFresh = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isFresh {
|
||||
break
|
||||
} else {
|
||||
prio--
|
||||
}
|
||||
}
|
||||
return prio
|
||||
}
|
||||
|
||||
func (c LocalController) setMainRule(rules []domain.RouteRule) error {
|
||||
var family domain.IpFamily
|
||||
shouldHaveMainRule := false
|
||||
for _, rule := range rules {
|
||||
family = rule.IpFamily
|
||||
if rule.HasDefault == true {
|
||||
shouldHaveMainRule = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !shouldHaveMainRule {
|
||||
return nil
|
||||
}
|
||||
|
||||
existingRules, err := c.nl.RuleList(int(family))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing rules for family %s: %w", family, err)
|
||||
}
|
||||
|
||||
ruleExists := false
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
|
||||
ruleExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ruleExists {
|
||||
return nil // rule already exists, skip re-creation
|
||||
}
|
||||
|
||||
if err := c.nl.RuleAdd(&netlink.Rule{
|
||||
Family: int(family),
|
||||
Table: unix.RT_TABLE_MAIN,
|
||||
SuppressIfgroup: -1,
|
||||
SuppressPrefixlen: 0,
|
||||
Priority: c.getMainRulePriority(existingRules),
|
||||
Mark: 0,
|
||||
Mask: nil,
|
||||
Goto: -1,
|
||||
Flow: -1,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to setup rule for main table: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) getMainRulePriority(existingRules []netlink.Rule) int {
|
||||
priority := c.cfg.Advanced.RulePrioOffset
|
||||
for {
|
||||
isFresh := true
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Priority == priority {
|
||||
isFresh = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isFresh {
|
||||
break
|
||||
} else {
|
||||
priority++
|
||||
}
|
||||
}
|
||||
return priority
|
||||
}
|
||||
|
||||
func (c LocalController) cleanupMainRule(rules []domain.RouteRule) error {
|
||||
var family domain.IpFamily
|
||||
for _, rule := range rules {
|
||||
family = rule.IpFamily
|
||||
break
|
||||
}
|
||||
|
||||
existingRules, err := c.nl.RuleList(int(family))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing rules for family %s: %w", family, err)
|
||||
}
|
||||
|
||||
shouldHaveMainRule := false
|
||||
for _, rule := range rules {
|
||||
if rule.HasDefault == true {
|
||||
shouldHaveMainRule = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
mainRules := 0
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
|
||||
mainRules++
|
||||
}
|
||||
}
|
||||
|
||||
removalCount := 0
|
||||
if mainRules > 1 {
|
||||
removalCount = mainRules - 1 // we only want one single rule
|
||||
}
|
||||
if !shouldHaveMainRule {
|
||||
removalCount = mainRules
|
||||
}
|
||||
|
||||
for _, existingRule := range existingRules {
|
||||
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
|
||||
if removalCount > 0 {
|
||||
existingRule.Family = int(family) // set family, somehow the RuleList method does not populate the family field
|
||||
if err := c.nl.RuleDel(&existingRule); err != nil {
|
||||
return fmt.Errorf("failed to delete main rule: %w", err)
|
||||
}
|
||||
removalCount--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c LocalController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
// endregion routing-related
|
||||
|
||||
// region statistics-related
|
||||
|
||||
func (c LocalController) PingAddresses(
|
||||
ctx context.Context,
|
||||
addr string,
|
||||
) (*domain.PingerResult, error) {
|
||||
pinger, err := probing.NewPinger(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to instantiate pinger for %s: %w", addr, err)
|
||||
}
|
||||
|
||||
checkCount := 1
|
||||
pinger.SetPrivileged(!c.cfg.Statistics.PingUnprivileged)
|
||||
pinger.Count = checkCount
|
||||
pinger.Timeout = 2 * time.Second
|
||||
err = pinger.RunWithContext(ctx) // Blocks until finished.
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to ping %s: %w", addr, err)
|
||||
}
|
||||
|
||||
stats := pinger.Statistics()
|
||||
|
||||
return &domain.PingerResult{
|
||||
PacketsRecv: stats.PacketsRecv,
|
||||
PacketsSent: stats.PacketsSent,
|
||||
Rtts: stats.Rtts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// endregion statistics-related
|
832
internal/adapters/wgcontroller/mikrotik.go
Normal file
832
internal/adapters/wgcontroller/mikrotik.go
Normal file
@@ -0,0 +1,832 @@
|
||||
package wgcontroller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"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"
|
||||
)
|
||||
|
||||
type MikrotikController struct {
|
||||
coreCfg *config.Config
|
||||
cfg *config.BackendMikrotik
|
||||
|
||||
client *lowlevel.MikrotikApiClient
|
||||
|
||||
// Add mutexes to prevent race conditions
|
||||
interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*sync.Mutex
|
||||
peerMutexes sync.Map // map[domain.PeerIdentifier]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewMikrotikController(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikController, error) {
|
||||
client, err := lowlevel.NewMikrotikApiClient(coreCfg, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Mikrotik API client: %w", err)
|
||||
}
|
||||
|
||||
return &MikrotikController{
|
||||
coreCfg: coreCfg,
|
||||
cfg: cfg,
|
||||
|
||||
client: client,
|
||||
|
||||
interfaceMutexes: sync.Map{},
|
||||
peerMutexes: sync.Map{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) GetId() domain.InterfaceBackend {
|
||||
return domain.InterfaceBackend(c.cfg.Id)
|
||||
}
|
||||
|
||||
// getInterfaceMutex returns a mutex for the given interface to prevent concurrent modifications
|
||||
func (c *MikrotikController) 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 *MikrotikController) getPeerMutex(id domain.PeerIdentifier) *sync.Mutex {
|
||||
mutex, _ := c.peerMutexes.LoadOrStore(id, &sync.Mutex{})
|
||||
return mutex.(*sync.Mutex)
|
||||
}
|
||||
|
||||
// region wireguard-related
|
||||
|
||||
func (c *MikrotikController) GetInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) {
|
||||
wgReply := c.client.Query(ctx, "/interface/wireguard", &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{
|
||||
".id", "name", "public-key", "private-key", "listen-port", "mtu", "disabled", "running", "comment",
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
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 MikroTik 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 *MikrotikController) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (
|
||||
*domain.PhysicalInterface,
|
||||
error,
|
||||
) {
|
||||
wgReply := c.client.Query(ctx, "/interface/wireguard", &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{
|
||||
".id", "name", "public-key", "private-key", "listen-port", "mtu", "disabled", "running",
|
||||
},
|
||||
Filters: map[string]string{
|
||||
"name": string(id),
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
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)
|
||||
}
|
||||
|
||||
return c.loadInterfaceData(ctx, wgReply.Data[0])
|
||||
}
|
||||
|
||||
func (c *MikrotikController) loadInterfaceData(
|
||||
ctx context.Context,
|
||||
wireGuardObj lowlevel.GenericJsonObject,
|
||||
) (*domain.PhysicalInterface, error) {
|
||||
deviceId := wireGuardObj.GetString(".id")
|
||||
deviceName := wireGuardObj.GetString("name")
|
||||
ifaceReply := c.client.Get(ctx, "/interface/"+deviceId, &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{
|
||||
"name", "rx-byte", "tx-byte",
|
||||
},
|
||||
})
|
||||
if ifaceReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return nil, fmt.Errorf("failed to query interface %s: %v", deviceId, ifaceReply.Error)
|
||||
}
|
||||
|
||||
ipv4, ipv6, err := c.loadIpAddresses(ctx, deviceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query IP addresses for interface %s: %v", deviceId, err)
|
||||
}
|
||||
addresses := c.convertIpAddresses(ipv4, ipv6)
|
||||
|
||||
interfaceModel, err := c.convertWireGuardInterface(wireGuardObj, ifaceReply.Data, addresses)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("interface convert failed for %s: %w", deviceName, err)
|
||||
}
|
||||
return &interfaceModel, nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) loadIpAddresses(
|
||||
ctx context.Context,
|
||||
deviceName string,
|
||||
) (ipv4 []lowlevel.GenericJsonObject, ipv6 []lowlevel.GenericJsonObject, err error) {
|
||||
// Query IPv4 and IPv6 addresses in parallel to reduce latency.
|
||||
var (
|
||||
v4 []lowlevel.GenericJsonObject
|
||||
v6 []lowlevel.GenericJsonObject
|
||||
v4Err error
|
||||
v6Err error
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
addrV4Reply := c.client.Query(ctx, "/ip/address", &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{
|
||||
".id", "address", "network",
|
||||
},
|
||||
Filters: map[string]string{
|
||||
"interface": deviceName,
|
||||
"dynamic": "false", // we only want static addresses
|
||||
"disabled": "false", // we only want addresses that are not disabled
|
||||
},
|
||||
})
|
||||
if addrV4Reply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
v4Err = fmt.Errorf("failed to query IPv4 addresses for interface %s: %v", deviceName, addrV4Reply.Error)
|
||||
return
|
||||
}
|
||||
v4 = addrV4Reply.Data
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
addrV6Reply := c.client.Query(ctx, "/ipv6/address", &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{
|
||||
".id", "address", "network",
|
||||
},
|
||||
Filters: map[string]string{
|
||||
"interface": deviceName,
|
||||
"dynamic": "false", // we only want static addresses
|
||||
"disabled": "false", // we only want addresses that are not disabled
|
||||
},
|
||||
})
|
||||
if addrV6Reply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
v6Err = fmt.Errorf("failed to query IPv6 addresses for interface %s: %v", deviceName, addrV6Reply.Error)
|
||||
return
|
||||
}
|
||||
v6 = addrV6Reply.Data
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
if v4Err != nil {
|
||||
return nil, nil, v4Err
|
||||
}
|
||||
if v6Err != nil {
|
||||
return nil, nil, v6Err
|
||||
}
|
||||
|
||||
return v4, v6, nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) convertIpAddresses(
|
||||
ipv4, ipv6 []lowlevel.GenericJsonObject,
|
||||
) []domain.Cidr {
|
||||
addresses := make([]domain.Cidr, 0, len(ipv4)+len(ipv6))
|
||||
for _, addr := range append(ipv4, ipv6...) {
|
||||
addrStr := addr.GetString("address")
|
||||
if addrStr == "" {
|
||||
continue
|
||||
}
|
||||
cidr, err := domain.CidrFromString(addrStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
addresses = append(addresses, cidr)
|
||||
}
|
||||
|
||||
return addresses
|
||||
}
|
||||
|
||||
func (c *MikrotikController) convertWireGuardInterface(
|
||||
wg, iface lowlevel.GenericJsonObject,
|
||||
addresses []domain.Cidr,
|
||||
) (
|
||||
domain.PhysicalInterface,
|
||||
error,
|
||||
) {
|
||||
pi := domain.PhysicalInterface{
|
||||
Identifier: domain.InterfaceIdentifier(wg.GetString("name")),
|
||||
KeyPair: domain.KeyPair{
|
||||
PrivateKey: wg.GetString("private-key"),
|
||||
PublicKey: wg.GetString("public-key"),
|
||||
},
|
||||
ListenPort: wg.GetInt("listen-port"),
|
||||
Addresses: addresses,
|
||||
Mtu: wg.GetInt("mtu"),
|
||||
FirewallMark: 0,
|
||||
DeviceUp: wg.GetBool("running"),
|
||||
ImportSource: domain.ControllerTypeMikrotik,
|
||||
DeviceType: domain.ControllerTypeMikrotik,
|
||||
BytesUpload: uint64(iface.GetInt("tx-byte")),
|
||||
BytesDownload: uint64(iface.GetInt("rx-byte")),
|
||||
}
|
||||
|
||||
pi.SetExtras(domain.MikrotikInterfaceExtras{
|
||||
Id: wg.GetString(".id"),
|
||||
Comment: wg.GetString("comment"),
|
||||
Disabled: wg.GetBool("disabled"),
|
||||
})
|
||||
|
||||
return pi, nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) GetPeers(ctx context.Context, deviceId domain.InterfaceIdentifier) (
|
||||
[]domain.PhysicalPeer,
|
||||
error,
|
||||
) {
|
||||
wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{
|
||||
".id", "name", "allowed-address", "client-address", "client-endpoint", "client-keepalive", "comment",
|
||||
"current-endpoint-address", "current-endpoint-port", "last-handshake", "persistent-keepalive",
|
||||
"public-key", "private-key", "preshared-key", "mtu", "disabled", "rx", "tx", "responder", "client-dns",
|
||||
},
|
||||
Filters: map[string]string{
|
||||
"interface": string(deviceId),
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return nil, fmt.Errorf("failed to query peers for %s: %v", deviceId, wgReply.Error)
|
||||
}
|
||||
|
||||
if len(wgReply.Data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
peers := make([]domain.PhysicalPeer, 0, len(wgReply.Data))
|
||||
for _, peer := range wgReply.Data {
|
||||
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)
|
||||
}
|
||||
|
||||
return peers, nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) convertWireGuardPeer(peer lowlevel.GenericJsonObject) (
|
||||
domain.PhysicalPeer,
|
||||
error,
|
||||
) {
|
||||
keepAliveSeconds := 0
|
||||
duration, err := time.ParseDuration(peer.GetString("persistent-keepalive"))
|
||||
if err == nil {
|
||||
keepAliveSeconds = int(duration.Seconds())
|
||||
}
|
||||
|
||||
currentEndpoint := ""
|
||||
if peer.GetString("current-endpoint-address") != "" && peer.GetString("current-endpoint-port") != "" {
|
||||
currentEndpoint = peer.GetString("current-endpoint-address") + ":" + peer.GetString("current-endpoint-port")
|
||||
}
|
||||
|
||||
lastHandshakeTime := time.Time{}
|
||||
if peer.GetString("last-handshake") != "" {
|
||||
relDuration, err := time.ParseDuration(peer.GetString("last-handshake"))
|
||||
if err == nil {
|
||||
lastHandshakeTime = time.Now().Add(-relDuration)
|
||||
}
|
||||
}
|
||||
|
||||
allowedAddresses, _ := domain.CidrsFromString(peer.GetString("allowed-address"))
|
||||
|
||||
clientKeepAliveSeconds := 0
|
||||
duration, err = time.ParseDuration(peer.GetString("client-keepalive"))
|
||||
if err == nil {
|
||||
clientKeepAliveSeconds = int(duration.Seconds())
|
||||
}
|
||||
|
||||
peerModel := domain.PhysicalPeer{
|
||||
Identifier: domain.PeerIdentifier(peer.GetString("public-key")),
|
||||
Endpoint: currentEndpoint,
|
||||
AllowedIPs: allowedAddresses,
|
||||
KeyPair: domain.KeyPair{
|
||||
PublicKey: peer.GetString("public-key"),
|
||||
PrivateKey: peer.GetString("private-key"),
|
||||
},
|
||||
PresharedKey: domain.PreSharedKey(peer.GetString("preshared-key")),
|
||||
PersistentKeepalive: keepAliveSeconds,
|
||||
LastHandshake: lastHandshakeTime,
|
||||
ProtocolVersion: 0, // Mikrotik does not support protocol versioning, so we set it to 0
|
||||
BytesUpload: uint64(peer.GetInt("rx")),
|
||||
BytesDownload: uint64(peer.GetInt("tx")),
|
||||
ImportSource: domain.ControllerTypeMikrotik,
|
||||
}
|
||||
|
||||
peerModel.SetExtras(domain.MikrotikPeerExtras{
|
||||
Id: peer.GetString(".id"),
|
||||
Name: peer.GetString("name"),
|
||||
Comment: peer.GetString("comment"),
|
||||
IsResponder: peer.GetBool("responder"),
|
||||
Disabled: peer.GetBool("disabled"),
|
||||
ClientEndpoint: peer.GetString("client-endpoint"),
|
||||
ClientAddress: peer.GetString("client-address"),
|
||||
ClientDns: peer.GetString("client-dns"),
|
||||
ClientKeepalive: clientKeepAliveSeconds,
|
||||
})
|
||||
|
||||
return peerModel, nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) 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 := physicalInterface.GetExtras().(domain.MikrotikInterfaceExtras).Id
|
||||
if updateFunc != nil {
|
||||
physicalInterface, err = updateFunc(physicalInterface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newExtras := physicalInterface.GetExtras().(domain.MikrotikInterfaceExtras)
|
||||
newExtras.Id = deviceId // ensure the ID is not changed
|
||||
physicalInterface.SetExtras(newExtras)
|
||||
}
|
||||
|
||||
if err := c.updateInterface(ctx, physicalInterface); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) getOrCreateInterface(
|
||||
ctx context.Context,
|
||||
id domain.InterfaceIdentifier,
|
||||
) (*domain.PhysicalInterface, error) {
|
||||
wgReply := c.client.Query(ctx, "/interface/wireguard", &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{
|
||||
".id", "name", "public-key", "private-key", "listen-port", "mtu", "disabled", "running",
|
||||
},
|
||||
Filters: map[string]string{
|
||||
"name": string(id),
|
||||
},
|
||||
})
|
||||
if wgReply.Status == lowlevel.MikrotikApiStatusOk && len(wgReply.Data) > 0 {
|
||||
return c.loadInterfaceData(ctx, wgReply.Data[0])
|
||||
}
|
||||
|
||||
// create a new interface if it does not exist
|
||||
createReply := c.client.Create(ctx, "/interface/wireguard", lowlevel.GenericJsonObject{
|
||||
"name": string(id),
|
||||
})
|
||||
if wgReply.Status == lowlevel.MikrotikApiStatusOk {
|
||||
return c.loadInterfaceData(ctx, createReply.Data)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to create interface %s: %v", id, createReply.Error)
|
||||
}
|
||||
|
||||
func (c *MikrotikController) updateInterface(ctx context.Context, pi *domain.PhysicalInterface) error {
|
||||
extras := pi.GetExtras().(domain.MikrotikInterfaceExtras)
|
||||
interfaceId := extras.Id
|
||||
wgReply := c.client.Update(ctx, "/interface/wireguard/"+interfaceId, lowlevel.GenericJsonObject{
|
||||
"name": pi.Identifier,
|
||||
"comment": extras.Comment,
|
||||
"mtu": strconv.Itoa(pi.Mtu),
|
||||
"listen-port": strconv.Itoa(pi.ListenPort),
|
||||
"private-key": pi.KeyPair.PrivateKey,
|
||||
"disabled": strconv.FormatBool(!pi.DeviceUp),
|
||||
})
|
||||
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to update interface %s: %v", pi.Identifier, wgReply.Error)
|
||||
}
|
||||
|
||||
// update the interface's addresses
|
||||
currentV4, currentV6, err := c.loadIpAddresses(ctx, string(pi.Identifier))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load current addresses for interface %s: %v", pi.Identifier, err)
|
||||
}
|
||||
currentAddresses := c.convertIpAddresses(currentV4, currentV6)
|
||||
|
||||
// get all addresses that are currently not in the interface, only in pi
|
||||
newAddresses := make([]domain.Cidr, 0, len(pi.Addresses))
|
||||
for _, addr := range pi.Addresses {
|
||||
if slices.Contains(currentAddresses, addr) {
|
||||
continue
|
||||
}
|
||||
newAddresses = append(newAddresses, addr)
|
||||
}
|
||||
// get obsolete addresses that are in the interface, but not in pi
|
||||
obsoleteAddresses := make([]domain.Cidr, 0, len(currentAddresses))
|
||||
for _, addr := range currentAddresses {
|
||||
if slices.Contains(pi.Addresses, addr) {
|
||||
continue
|
||||
}
|
||||
obsoleteAddresses = append(obsoleteAddresses, addr)
|
||||
}
|
||||
|
||||
// update the IP addresses for the interface
|
||||
if err := c.updateIpAddresses(ctx, string(pi.Identifier), currentV4, currentV6,
|
||||
newAddresses, obsoleteAddresses); err != nil {
|
||||
return fmt.Errorf("failed to update IP addresses for interface %s: %v", pi.Identifier, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) updateIpAddresses(
|
||||
ctx context.Context,
|
||||
deviceName string,
|
||||
currentV4, currentV6 []lowlevel.GenericJsonObject,
|
||||
new, obsolete []domain.Cidr,
|
||||
) error {
|
||||
// first, delete all obsolete addresses
|
||||
for _, addr := range obsolete {
|
||||
// find ID of the address to delete
|
||||
if addr.IsV4() {
|
||||
for _, a := range currentV4 {
|
||||
if a.GetString("address") == addr.String() {
|
||||
// delete the address
|
||||
reply := c.client.Delete(ctx, "/ip/address/"+a.GetString(".id"))
|
||||
if reply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to delete obsolete IPv4 address %s: %v", addr, reply.Error)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, a := range currentV6 {
|
||||
if a.GetString("address") == addr.String() {
|
||||
// delete the address
|
||||
reply := c.client.Delete(ctx, "/ipv6/address/"+a.GetString(".id"))
|
||||
if reply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to delete obsolete IPv6 address %s: %v", addr, reply.Error)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// then, add all new addresses
|
||||
for _, addr := range new {
|
||||
var createPath string
|
||||
if addr.IsV4() {
|
||||
createPath = "/ip/address"
|
||||
} else {
|
||||
createPath = "/ipv6/address"
|
||||
}
|
||||
|
||||
// create the address
|
||||
reply := c.client.Create(ctx, createPath, lowlevel.GenericJsonObject{
|
||||
"address": addr.String(),
|
||||
"interface": deviceName,
|
||||
})
|
||||
if reply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to create new address %s: %v", addr, reply.Error)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error {
|
||||
// Lock the interface to prevent concurrent modifications
|
||||
mutex := c.getInterfaceMutex(id)
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
// delete the interface's addresses
|
||||
currentV4, currentV6, err := c.loadIpAddresses(ctx, string(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load current addresses for interface %s: %v", id, err)
|
||||
}
|
||||
for _, a := range currentV4 {
|
||||
// delete the address
|
||||
reply := c.client.Delete(ctx, "/ip/address/"+a.GetString(".id"))
|
||||
if reply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to delete IPv4 address %s: %v", a.GetString("address"), reply.Error)
|
||||
}
|
||||
}
|
||||
for _, a := range currentV6 {
|
||||
// delete the address
|
||||
reply := c.client.Delete(ctx, "/ipv6/address/"+a.GetString(".id"))
|
||||
if reply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to delete IPv6 address %s: %v", a.GetString("address"), reply.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// delete the WireGuard interface
|
||||
wgReply := c.client.Query(ctx, "/interface/wireguard", &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{".id"},
|
||||
Filters: map[string]string{
|
||||
"name": string(id),
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("unable to find WireGuard interface %s: %v", id, wgReply.Error)
|
||||
}
|
||||
if len(wgReply.Data) == 0 {
|
||||
return nil // interface does not exist, nothing to delete
|
||||
}
|
||||
|
||||
interfaceId := wgReply.Data[0].GetString(".id")
|
||||
deleteReply := c.client.Delete(ctx, "/interface/wireguard/"+interfaceId)
|
||||
if deleteReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to delete WireGuard interface %s: %v", id, deleteReply.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) 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 := physicalPeer.GetExtras().(domain.MikrotikPeerExtras).Id
|
||||
physicalPeer, err = updateFunc(physicalPeer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newExtras := physicalPeer.GetExtras().(domain.MikrotikPeerExtras)
|
||||
newExtras.Id = peerId // ensure the ID is not changed
|
||||
physicalPeer.SetExtras(newExtras)
|
||||
|
||||
if err := c.updatePeer(ctx, deviceId, physicalPeer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) getOrCreatePeer(
|
||||
ctx context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
) (*domain.PhysicalPeer, error) {
|
||||
wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{
|
||||
".id", "name", "public-key", "private-key", "preshared-key", "persistent-keepalive", "client-address",
|
||||
"client-endpoint", "client-keepalive", "allowed-address", "client-dns", "comment", "disabled", "responder",
|
||||
},
|
||||
Filters: map[string]string{
|
||||
"public-key": string(id),
|
||||
"interface": string(deviceId),
|
||||
},
|
||||
})
|
||||
if wgReply.Status == lowlevel.MikrotikApiStatusOk && len(wgReply.Data) > 0 {
|
||||
slog.Debug("found existing Mikrotik 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
|
||||
slog.Debug("creating new Mikrotik peer", "peer", id, "interface", deviceId)
|
||||
createReply := c.client.Create(ctx, "/interface/wireguard/peers", lowlevel.GenericJsonObject{
|
||||
"name": fmt.Sprintf("tmp-wg-%s", id[0:8]),
|
||||
"interface": string(deviceId),
|
||||
"public-key": string(id),
|
||||
"allowed-address": "0.0.0.0/0", // Use 0.0.0.0/0 as default, will be updated by updatePeer
|
||||
})
|
||||
if createReply.Status == lowlevel.MikrotikApiStatusOk {
|
||||
newPeer, err := c.convertWireGuardPeer(createReply.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Debug("successfully created Mikrotik 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 *MikrotikController) updatePeer(
|
||||
ctx context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
pp *domain.PhysicalPeer,
|
||||
) error {
|
||||
extras := pp.GetExtras().(domain.MikrotikPeerExtras)
|
||||
peerId := extras.Id
|
||||
|
||||
endpoint := "" // by default, we have no endpoint (the peer does not initiate a connection)
|
||||
endpointPort := "0" // by default, we have no endpoint port (the peer does not initiate a connection)
|
||||
if !extras.IsResponder { // if the peer is not only a responder, it needs the endpoint to initiate a connection
|
||||
endpoint = pp.Endpoint
|
||||
endpointPort = "51820" // default port if not set
|
||||
if s := strings.Split(endpoint, ":"); len(s) == 2 {
|
||||
endpoint = s[0]
|
||||
endpointPort = s[1]
|
||||
}
|
||||
}
|
||||
|
||||
allowedAddressStr := domain.CidrsToString(pp.AllowedIPs)
|
||||
slog.Debug("updating Mikrotik peer",
|
||||
"peer", pp.Identifier,
|
||||
"interface", deviceId,
|
||||
"allowed-address", allowedAddressStr,
|
||||
"allowed-ips-count", len(pp.AllowedIPs),
|
||||
"disabled", extras.Disabled)
|
||||
|
||||
wgReply := c.client.Update(ctx, "/interface/wireguard/peers/"+peerId, lowlevel.GenericJsonObject{
|
||||
"name": extras.Name,
|
||||
"comment": extras.Comment,
|
||||
"preshared-key": pp.PresharedKey,
|
||||
"public-key": pp.KeyPair.PublicKey,
|
||||
"private-key": pp.KeyPair.PrivateKey,
|
||||
"persistent-keepalive": (time.Duration(pp.PersistentKeepalive) * time.Second).String(),
|
||||
"disabled": strconv.FormatBool(extras.Disabled),
|
||||
"responder": strconv.FormatBool(extras.IsResponder),
|
||||
"client-endpoint": extras.ClientEndpoint,
|
||||
"client-address": extras.ClientAddress,
|
||||
"client-keepalive": (time.Duration(extras.ClientKeepalive) * time.Second).String(),
|
||||
"client-dns": extras.ClientDns,
|
||||
"endpoint-address": endpoint,
|
||||
"endpoint-port": endpointPort,
|
||||
"allowed-address": allowedAddressStr, // Add the missing allowed-address field
|
||||
})
|
||||
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return fmt.Errorf("failed to update peer %s on interface %s: %v", pp.Identifier, deviceId, wgReply.Error)
|
||||
}
|
||||
|
||||
if extras.Disabled {
|
||||
slog.Debug("successfully disabled Mikrotik peer", "peer", pp.Identifier, "interface", deviceId)
|
||||
} else {
|
||||
slog.Debug("successfully updated Mikrotik peer", "peer", pp.Identifier, "interface", deviceId)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MikrotikController) 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()
|
||||
|
||||
wgReply := c.client.Query(ctx, "/interface/wireguard/peers", &lowlevel.MikrotikRequestOptions{
|
||||
PropList: []string{".id"},
|
||||
Filters: map[string]string{
|
||||
"public-key": string(id),
|
||||
"interface": string(deviceId),
|
||||
},
|
||||
})
|
||||
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
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")
|
||||
deleteReply := c.client.Delete(ctx, "/interface/wireguard/peers/"+peerId)
|
||||
if deleteReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
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 *MikrotikController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (c *MikrotikController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (c *MikrotikController) UnsetDNS(id domain.InterfaceIdentifier) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
// endregion wg-quick-related
|
||||
|
||||
// region routing-related
|
||||
|
||||
func (c *MikrotikController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (c *MikrotikController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
// endregion routing-related
|
||||
|
||||
// region statistics-related
|
||||
|
||||
func (c *MikrotikController) PingAddresses(
|
||||
ctx context.Context,
|
||||
addr string,
|
||||
) (*domain.PingerResult, error) {
|
||||
wgReply := c.client.ExecList(ctx, "/tool/ping",
|
||||
// limit to 1 packet with a max running time of 2 seconds
|
||||
lowlevel.GenericJsonObject{"address": addr, "count": 1, "interval": "00:00:02"},
|
||||
)
|
||||
|
||||
if wgReply.Status != lowlevel.MikrotikApiStatusOk {
|
||||
return nil, fmt.Errorf("failed to ping %s: %v", addr, wgReply.Error)
|
||||
}
|
||||
|
||||
var result domain.PingerResult
|
||||
for _, item := range wgReply.Data {
|
||||
result.PacketsRecv += item.GetInt("received")
|
||||
result.PacketsSent += item.GetInt("sent")
|
||||
|
||||
rttStr := item.GetString("avg-rtt")
|
||||
if rttStr != "" {
|
||||
rtt, err := time.ParseDuration(rttStr)
|
||||
if err == nil {
|
||||
result.Rtts = append(result.Rtts, rtt)
|
||||
} else {
|
||||
// use a high value to indicate failure or timeout
|
||||
result.Rtts = append(result.Rtts, 999999*time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// endregion statistics-related
|
@@ -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
|
||||
}
|
||||
|
||||
|
@@ -57,6 +57,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/login/{provider}/callback": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Handle the OAuth callback.",
|
||||
"operationId": "auth_handleOauthCallbackGet",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.LoginProviderInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/login/{provider}/init": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Initiate the OAuth login flow.",
|
||||
"operationId": "auth_handleOauthInitiateGet",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.LoginProviderInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/logout": {
|
||||
"post": {
|
||||
"produces": [
|
||||
@@ -129,30 +175,78 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/{provider}/callback": {
|
||||
"get": {
|
||||
"/auth/webauthn/credential/{id}": {
|
||||
"put": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Handle the OAuth callback.",
|
||||
"operationId": "auth_handleOauthCallbackGet",
|
||||
"summary": "Update a WebAuthn credential.",
|
||||
"operationId": "auth_handleWebAuthnCredentialsPut",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Base64 encoded Credential ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Credential name",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.WebAuthnCredentialRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.LoginProviderInfo"
|
||||
"$ref": "#/definitions/model.WebAuthnCredentialResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Delete a WebAuthn credential.",
|
||||
"operationId": "auth_handleWebAuthnCredentialsDelete",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Base64 encoded Credential ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.WebAuthnCredentialResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/{provider}/init": {
|
||||
"/auth/webauthn/credentials": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -160,15 +254,67 @@
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Initiate the OAuth login flow.",
|
||||
"operationId": "auth_handleOauthInitiateGet",
|
||||
"summary": "Get all available external login providers.",
|
||||
"operationId": "auth_handleWebAuthnCredentialsGet",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.LoginProviderInfo"
|
||||
"$ref": "#/definitions/model.WebAuthnCredentialResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/webauthn/login/finish": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Finish the WebAuthn login process.",
|
||||
"operationId": "auth_handleWebAuthnLoginFinish",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.User"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/webauthn/register/finish": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Finish the WebAuthn registration process.",
|
||||
"operationId": "auth_handleWebAuthnRegisterFinish",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"default": "\"\"",
|
||||
"description": "Credential name",
|
||||
"name": "credential_name",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.WebAuthnCredentialResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -673,6 +819,12 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.PeerMailRequest"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The configuration style",
|
||||
"name": "style",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -712,6 +864,12 @@
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The configuration style",
|
||||
"name": "style",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -753,6 +911,12 @@
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The configuration style",
|
||||
"name": "style",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -1617,6 +1781,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"
|
||||
@@ -1805,7 +1974,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Suffix": {
|
||||
"Prefix": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -2085,14 +2254,40 @@
|
||||
"ApiAdminOnly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"AvailableBackends": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.SettingsBackendNames"
|
||||
}
|
||||
},
|
||||
"LoginFormVisible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"MailLinkOnly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"MinPasswordLength": {
|
||||
"type": "integer"
|
||||
},
|
||||
"PersistentConfigSupported": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"SelfProvisioning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"WebAuthnEnabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.SettingsBackendNames": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Id": {
|
||||
"type": "string"
|
||||
},
|
||||
"Name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2161,6 +2356,28 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.WebAuthnCredentialRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.WebAuthnCredentialResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"CreatedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"ID": {
|
||||
"type": "string"
|
||||
},
|
||||
"Name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -65,6 +65,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 +210,7 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
Suffix:
|
||||
Prefix:
|
||||
type: string
|
||||
type: object
|
||||
model.Peer:
|
||||
@@ -381,12 +385,29 @@ definitions:
|
||||
properties:
|
||||
ApiAdminOnly:
|
||||
type: boolean
|
||||
AvailableBackends:
|
||||
items:
|
||||
$ref: '#/definitions/model.SettingsBackendNames'
|
||||
type: array
|
||||
LoginFormVisible:
|
||||
type: boolean
|
||||
MailLinkOnly:
|
||||
type: boolean
|
||||
MinPasswordLength:
|
||||
type: integer
|
||||
PersistentConfigSupported:
|
||||
type: boolean
|
||||
SelfProvisioning:
|
||||
type: boolean
|
||||
WebAuthnEnabled:
|
||||
type: boolean
|
||||
type: object
|
||||
model.SettingsBackendNames:
|
||||
properties:
|
||||
Id:
|
||||
type: string
|
||||
Name:
|
||||
type: string
|
||||
type: object
|
||||
model.User:
|
||||
properties:
|
||||
@@ -433,6 +454,20 @@ definitions:
|
||||
Source:
|
||||
type: string
|
||||
type: object
|
||||
model.WebAuthnCredentialRequest:
|
||||
properties:
|
||||
Name:
|
||||
type: string
|
||||
type: object
|
||||
model.WebAuthnCredentialResponse:
|
||||
properties:
|
||||
CreatedAt:
|
||||
type: string
|
||||
ID:
|
||||
type: string
|
||||
Name:
|
||||
type: string
|
||||
type: object
|
||||
info:
|
||||
contact:
|
||||
name: WireGuard Portal Developers
|
||||
@@ -456,7 +491,22 @@ paths:
|
||||
summary: Get all available audit entries. Ordered by timestamp.
|
||||
tags:
|
||||
- Audit
|
||||
/auth/{provider}/callback:
|
||||
/auth/login:
|
||||
post:
|
||||
operationId: auth_handleLoginPost
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.LoginProviderInfo'
|
||||
type: array
|
||||
summary: Get all available external login providers.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/login/{provider}/callback:
|
||||
get:
|
||||
operationId: auth_handleOauthCallbackGet
|
||||
produces:
|
||||
@@ -471,7 +521,7 @@ paths:
|
||||
summary: Handle the OAuth callback.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/{provider}/init:
|
||||
/auth/login/{provider}/init:
|
||||
get:
|
||||
operationId: auth_handleOauthInitiateGet
|
||||
produces:
|
||||
@@ -486,21 +536,6 @@ paths:
|
||||
summary: Initiate the OAuth login flow.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/login:
|
||||
post:
|
||||
operationId: auth_handleLoginPost
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.LoginProviderInfo'
|
||||
type: array
|
||||
summary: Get all available external login providers.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/logout:
|
||||
post:
|
||||
operationId: auth_handleLogoutPost
|
||||
@@ -548,6 +583,102 @@ paths:
|
||||
summary: Get information about the currently logged-in user.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/webauthn/credential/{id}:
|
||||
delete:
|
||||
operationId: auth_handleWebAuthnCredentialsDelete
|
||||
parameters:
|
||||
- description: Base64 encoded Credential ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.WebAuthnCredentialResponse'
|
||||
type: array
|
||||
summary: Delete a WebAuthn credential.
|
||||
tags:
|
||||
- Authentication
|
||||
put:
|
||||
operationId: auth_handleWebAuthnCredentialsPut
|
||||
parameters:
|
||||
- description: Base64 encoded Credential ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Credential name
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/model.WebAuthnCredentialRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.WebAuthnCredentialResponse'
|
||||
type: array
|
||||
summary: Update a WebAuthn credential.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/webauthn/credentials:
|
||||
get:
|
||||
operationId: auth_handleWebAuthnCredentialsGet
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.WebAuthnCredentialResponse'
|
||||
type: array
|
||||
summary: Get all available external login providers.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/webauthn/login/finish:
|
||||
post:
|
||||
operationId: auth_handleWebAuthnLoginFinish
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/model.User'
|
||||
summary: Finish the WebAuthn login process.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/webauthn/register/finish:
|
||||
post:
|
||||
operationId: auth_handleWebAuthnRegisterFinish
|
||||
parameters:
|
||||
- default: '""'
|
||||
description: Credential name
|
||||
in: query
|
||||
name: credential_name
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.WebAuthnCredentialResponse'
|
||||
type: array
|
||||
summary: Finish the WebAuthn registration process.
|
||||
tags:
|
||||
- Authentication
|
||||
/config/frontend.js:
|
||||
get:
|
||||
operationId: config_handleConfigJsGet
|
||||
@@ -956,6 +1087,10 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/model.PeerMailRequest'
|
||||
- description: The configuration style
|
||||
in: query
|
||||
name: style
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -981,6 +1116,10 @@ paths:
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: The configuration style
|
||||
in: query
|
||||
name: style
|
||||
type: string
|
||||
produces:
|
||||
- image/png
|
||||
- application/json
|
||||
@@ -1009,6 +1148,10 @@ paths:
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: The configuration style
|
||||
in: query
|
||||
name: style
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
|
@@ -118,6 +118,7 @@
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"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"
|
||||
],
|
||||
@@ -250,6 +251,7 @@
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "This endpoint creates a new interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -309,6 +311,50 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/interface/prepare": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Interfaces"
|
||||
],
|
||||
"summary": "Prepare a new interface record.",
|
||||
"operationId": "interfaces_handlePrepareGet",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Interface"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/metrics/by-interface/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -547,7 +593,7 @@
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Only admins can update existing records.",
|
||||
"description": "Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -779,7 +825,7 @@
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Only admins can create new records.",
|
||||
"description": "Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -839,6 +885,71 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/peer/prepare/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Peers"
|
||||
],
|
||||
"summary": "Prepare a new peer record for the given WireGuard interface.",
|
||||
"operationId": "peers_handlePrepareGet",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The interface identifier.",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Peer"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/provisioning/data/peer-config": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -1975,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",
|
||||
|
@@ -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.
|
||||
@@ -748,6 +754,8 @@ paths:
|
||||
tags:
|
||||
- Interfaces
|
||||
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, ...).
|
||||
operationId: interfaces_handleUpdatePut
|
||||
parameters:
|
||||
- description: The interface identifier.
|
||||
@@ -795,6 +803,8 @@ paths:
|
||||
- Interfaces
|
||||
/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, ...).
|
||||
operationId: interfaces_handleCreatePost
|
||||
parameters:
|
||||
- description: The interface data.
|
||||
@@ -835,6 +845,35 @@ paths:
|
||||
summary: Create a new interface record.
|
||||
tags:
|
||||
- Interfaces
|
||||
/interface/prepare:
|
||||
get:
|
||||
description: This endpoint returns a new interface with default values (fresh
|
||||
key pair, valid name, new IP address pool, ...).
|
||||
operationId: interfaces_handlePrepareGet
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.Interface'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
security:
|
||||
- BasicAuth: []
|
||||
summary: Prepare a new interface record.
|
||||
tags:
|
||||
- Interfaces
|
||||
/metrics/by-interface/{id}:
|
||||
get:
|
||||
operationId: metrics_handleMetricsForInterfaceGet
|
||||
@@ -1024,7 +1063,8 @@ paths:
|
||||
tags:
|
||||
- Peers
|
||||
put:
|
||||
description: Only admins can update existing records.
|
||||
description: Only admins can update existing records. The peer record must contain
|
||||
all required fields (e.g., public key, allowed IPs).
|
||||
operationId: peers_handleUpdatePut
|
||||
parameters:
|
||||
- description: The peer identifier.
|
||||
@@ -1136,7 +1176,8 @@ paths:
|
||||
- Peers
|
||||
/peer/new:
|
||||
post:
|
||||
description: Only admins can create new records.
|
||||
description: Only admins can create new records. The peer record must contain
|
||||
all required fields (e.g., public key, allowed IPs).
|
||||
operationId: peers_handleCreatePost
|
||||
parameters:
|
||||
- description: The peer data.
|
||||
@@ -1177,6 +1218,49 @@ paths:
|
||||
summary: Create a new peer record.
|
||||
tags:
|
||||
- Peers
|
||||
/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.
|
||||
operationId: peers_handlePrepareGet
|
||||
parameters:
|
||||
- description: The interface identifier.
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.Peer'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/models.Error'
|
||||
security:
|
||||
- BasicAuth: []
|
||||
summary: Prepare a new peer record for the given WireGuard interface.
|
||||
tags:
|
||||
- Peers
|
||||
/provisioning/data/peer-config:
|
||||
get:
|
||||
description: Normal users can only access their own record. Admins can access
|
||||
|
@@ -100,6 +100,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 {
|
||||
@@ -138,7 +139,8 @@ func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -27,12 +27,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,16 +95,24 @@ 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) {
|
||||
|
@@ -99,6 +99,8 @@ type Authenticator interface {
|
||||
LoggedIn(scopes ...Scope) func(next http.Handler) http.Handler
|
||||
// UserIdMatch checks if the user id in the session matches the user id in the request. If not, the request is aborted.
|
||||
UserIdMatch(idParameter string) func(next http.Handler) http.Handler
|
||||
// InfoOnly only add user info to the request context. No login check is performed.
|
||||
InfoOnly() func(next http.Handler) http.Handler
|
||||
}
|
||||
|
||||
type Session interface {
|
||||
|
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -28,12 +29,54 @@ type AuthenticationService interface {
|
||||
OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error)
|
||||
}
|
||||
|
||||
type WebAuthnService interface {
|
||||
Enabled() bool
|
||||
StartWebAuthnRegistration(ctx context.Context, userId domain.UserIdentifier) (
|
||||
responseOptions []byte,
|
||||
sessionData []byte,
|
||||
err error,
|
||||
)
|
||||
FinishWebAuthnRegistration(
|
||||
ctx context.Context,
|
||||
userId domain.UserIdentifier,
|
||||
name string,
|
||||
sessionDataAsJSON []byte,
|
||||
r *http.Request,
|
||||
) ([]domain.UserWebauthnCredential, error)
|
||||
GetCredentials(
|
||||
ctx context.Context,
|
||||
userId domain.UserIdentifier,
|
||||
) ([]domain.UserWebauthnCredential, error)
|
||||
RemoveCredential(
|
||||
ctx context.Context,
|
||||
userId domain.UserIdentifier,
|
||||
credentialIdBase64 string,
|
||||
) ([]domain.UserWebauthnCredential, error)
|
||||
UpdateCredential(
|
||||
ctx context.Context,
|
||||
userId domain.UserIdentifier,
|
||||
credentialIdBase64 string,
|
||||
name string,
|
||||
) ([]domain.UserWebauthnCredential, error)
|
||||
StartWebAuthnLogin(_ context.Context) (
|
||||
optionsAsJSON []byte,
|
||||
sessionDataAsJSON []byte,
|
||||
err error,
|
||||
)
|
||||
FinishWebAuthnLogin(
|
||||
ctx context.Context,
|
||||
sessionDataAsJSON []byte,
|
||||
r *http.Request,
|
||||
) (*domain.User, error)
|
||||
}
|
||||
|
||||
type AuthEndpoint struct {
|
||||
cfg *config.Config
|
||||
authService AuthenticationService
|
||||
authenticator Authenticator
|
||||
session Session
|
||||
validate Validator
|
||||
webAuthn WebAuthnService
|
||||
}
|
||||
|
||||
func NewAuthEndpoint(
|
||||
@@ -42,6 +85,7 @@ func NewAuthEndpoint(
|
||||
session Session,
|
||||
validator Validator,
|
||||
authService AuthenticationService,
|
||||
webAuthn WebAuthnService,
|
||||
) AuthEndpoint {
|
||||
return AuthEndpoint{
|
||||
cfg: cfg,
|
||||
@@ -49,6 +93,7 @@ func NewAuthEndpoint(
|
||||
authenticator: authenticator,
|
||||
session: session,
|
||||
validate: validator,
|
||||
webAuthn: webAuthn,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +110,19 @@ func (e AuthEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
||||
apiGroup.HandleFunc("GET /login/{provider}/init", e.handleOauthInitiateGet())
|
||||
apiGroup.HandleFunc("GET /login/{provider}/callback", e.handleOauthCallbackGet())
|
||||
|
||||
apiGroup.HandleFunc("POST /webauthn/login/start", e.handleWebAuthnLoginStart())
|
||||
apiGroup.HandleFunc("POST /webauthn/login/finish", e.handleWebAuthnLoginFinish())
|
||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("GET /webauthn/credentials",
|
||||
e.handleWebAuthnCredentialsGet())
|
||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /webauthn/register/start",
|
||||
e.handleWebAuthnRegisterStart())
|
||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /webauthn/register/finish",
|
||||
e.handleWebAuthnRegisterFinish())
|
||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("DELETE /webauthn/credential/{id}",
|
||||
e.handleWebAuthnCredentialsDelete())
|
||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("PUT /webauthn/credential/{id}",
|
||||
e.handleWebAuthnCredentialsPut())
|
||||
|
||||
apiGroup.HandleFunc("POST /login", e.handleLoginPost())
|
||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /logout", e.handleLogoutPost())
|
||||
}
|
||||
@@ -132,7 +190,7 @@ func (e AuthEndpoint) handleSessionInfoGet() http.HandlerFunc {
|
||||
// @Summary Initiate the OAuth login flow.
|
||||
// @Produce json
|
||||
// @Success 200 {object} []model.LoginProviderInfo
|
||||
// @Router /auth/{provider}/init [get]
|
||||
// @Router /auth/login/{provider}/init [get]
|
||||
func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
@@ -177,6 +235,8 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
|
||||
|
||||
authCodeUrl, state, nonce, err := e.authService.OauthLoginStep1(context.Background(), provider)
|
||||
if err != nil {
|
||||
slog.Debug("failed to create oauth auth code URL",
|
||||
"provider", provider, "error", err)
|
||||
if autoRedirect && e.isValidReturnUrl(returnTo) {
|
||||
redirectToReturn()
|
||||
} else {
|
||||
@@ -211,7 +271,7 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
|
||||
// @Summary Handle the OAuth callback.
|
||||
// @Produce json
|
||||
// @Success 200 {object} []model.LoginProviderInfo
|
||||
// @Router /auth/{provider}/callback [get]
|
||||
// @Router /auth/login/{provider}/callback [get]
|
||||
func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
@@ -249,6 +309,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
|
||||
oauthState := request.Query(r, "state")
|
||||
|
||||
if provider != currentSession.OauthProvider {
|
||||
slog.Debug("invalid oauth provider in callback",
|
||||
"expected", currentSession.OauthProvider, "got", provider, "state", oauthState)
|
||||
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
|
||||
redirectToReturn()
|
||||
} else {
|
||||
@@ -258,6 +320,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if oauthState != currentSession.OauthState {
|
||||
slog.Debug("invalid oauth state in callback",
|
||||
"expected", currentSession.OauthState, "got", oauthState, "provider", provider)
|
||||
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
|
||||
redirectToReturn()
|
||||
} else {
|
||||
@@ -267,11 +331,13 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
loginCtx, cancel := context.WithTimeout(context.Background(), 1000*time.Second)
|
||||
loginCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // avoid long waits
|
||||
user, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce,
|
||||
oauthCode)
|
||||
cancel()
|
||||
if err != nil {
|
||||
slog.Debug("failed to process oauth code",
|
||||
"provider", provider, "state", oauthState, "error", err)
|
||||
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
|
||||
redirectToReturn()
|
||||
} else {
|
||||
@@ -389,3 +455,237 @@ func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// handleWebAuthnCredentialsGet returns a gorm Handler function.
|
||||
//
|
||||
// @ID auth_handleWebAuthnCredentialsGet
|
||||
// @Tags Authentication
|
||||
// @Summary Get all available external login providers.
|
||||
// @Produce json
|
||||
// @Success 200 {object} []model.WebAuthnCredentialResponse
|
||||
// @Router /auth/webauthn/credentials [get]
|
||||
func (e AuthEndpoint) handleWebAuthnCredentialsGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.webAuthn.Enabled() {
|
||||
respond.JSON(w, http.StatusOK, []model.WebAuthnCredentialResponse{})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
|
||||
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
|
||||
|
||||
credentials, err := e.webAuthn.GetCredentials(r.Context(), userIdentifier)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebAuthnCredentialsDelete returns a gorm Handler function.
|
||||
//
|
||||
// @ID auth_handleWebAuthnCredentialsDelete
|
||||
// @Tags Authentication
|
||||
// @Summary Delete a WebAuthn credential.
|
||||
// @Param id path string true "Base64 encoded Credential ID"
|
||||
// @Produce json
|
||||
// @Success 200 {object} []model.WebAuthnCredentialResponse
|
||||
// @Router /auth/webauthn/credential/{id} [delete]
|
||||
func (e AuthEndpoint) handleWebAuthnCredentialsDelete() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.webAuthn.Enabled() {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
|
||||
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
|
||||
|
||||
credentialId := Base64UrlDecode(request.Path(r, "id"))
|
||||
|
||||
credentials, err := e.webAuthn.RemoveCredential(r.Context(), userIdentifier, credentialId)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebAuthnCredentialsPut returns a gorm Handler function.
|
||||
//
|
||||
// @ID auth_handleWebAuthnCredentialsPut
|
||||
// @Tags Authentication
|
||||
// @Summary Update a WebAuthn credential.
|
||||
// @Param id path string true "Base64 encoded Credential ID"
|
||||
// @Param request body model.WebAuthnCredentialRequest true "Credential name"
|
||||
// @Produce json
|
||||
// @Success 200 {object} []model.WebAuthnCredentialResponse
|
||||
// @Router /auth/webauthn/credential/{id} [put]
|
||||
func (e AuthEndpoint) handleWebAuthnCredentialsPut() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.webAuthn.Enabled() {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
|
||||
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
|
||||
|
||||
credentialId := Base64UrlDecode(request.Path(r, "id"))
|
||||
var req model.WebAuthnCredentialRequest
|
||||
if err := request.BodyJson(r, &req); err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
credentials, err := e.webAuthn.UpdateCredential(r.Context(), userIdentifier, credentialId, req.Name)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
|
||||
}
|
||||
}
|
||||
|
||||
func (e AuthEndpoint) handleWebAuthnRegisterStart() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.webAuthn.Enabled() {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
|
||||
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
|
||||
|
||||
options, sessionData, err := e.webAuthn.StartWebAuthnRegistration(r.Context(), userIdentifier)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession.WebAuthnData = string(sessionData)
|
||||
e.session.SetData(r.Context(), currentSession)
|
||||
|
||||
respond.Data(w, http.StatusOK, "application/json", options)
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebAuthnRegisterFinish returns a gorm Handler function.
|
||||
//
|
||||
// @ID auth_handleWebAuthnRegisterFinish
|
||||
// @Tags Authentication
|
||||
// @Summary Finish the WebAuthn registration process.
|
||||
// @Param credential_name query string false "Credential name" default("")
|
||||
// @Produce json
|
||||
// @Success 200 {object} []model.WebAuthnCredentialResponse
|
||||
// @Router /auth/webauthn/register/finish [post]
|
||||
func (e AuthEndpoint) handleWebAuthnRegisterFinish() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.webAuthn.Enabled() {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
name := request.QueryDefault(r, "credential_name", "")
|
||||
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
|
||||
webAuthnSessionData := []byte(currentSession.WebAuthnData)
|
||||
currentSession.WebAuthnData = "" // clear the session data
|
||||
e.session.SetData(r.Context(), currentSession)
|
||||
|
||||
credentials, err := e.webAuthn.FinishWebAuthnRegistration(
|
||||
r.Context(),
|
||||
domain.UserIdentifier(currentSession.UserIdentifier),
|
||||
name,
|
||||
webAuthnSessionData,
|
||||
r)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
|
||||
}
|
||||
}
|
||||
|
||||
func (e AuthEndpoint) handleWebAuthnLoginStart() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.webAuthn.Enabled() {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
|
||||
options, sessionData, err := e.webAuthn.StartWebAuthnLogin(r.Context())
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession.WebAuthnData = string(sessionData)
|
||||
e.session.SetData(r.Context(), currentSession)
|
||||
|
||||
respond.Data(w, http.StatusOK, "application/json", options)
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebAuthnLoginFinish returns a gorm Handler function.
|
||||
//
|
||||
// @ID auth_handleWebAuthnLoginFinish
|
||||
// @Tags Authentication
|
||||
// @Summary Finish the WebAuthn login process.
|
||||
// @Produce json
|
||||
// @Success 200 {object} model.User
|
||||
// @Router /auth/webauthn/login/finish [post]
|
||||
func (e AuthEndpoint) handleWebAuthnLoginFinish() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.webAuthn.Enabled() {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
|
||||
webAuthnSessionData := []byte(currentSession.WebAuthnData)
|
||||
currentSession.WebAuthnData = "" // clear the session data
|
||||
e.session.SetData(r.Context(), currentSession)
|
||||
|
||||
user, err := e.webAuthn.FinishWebAuthnLogin(
|
||||
r.Context(),
|
||||
webAuthnSessionData,
|
||||
r)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
e.setAuthenticatedUser(r, user)
|
||||
|
||||
respond.JSON(w, http.StatusOK, model.NewUser(user, false))
|
||||
}
|
||||
}
|
||||
|
@@ -15,22 +15,29 @@ import (
|
||||
"github.com/h44z/wg-portal/internal/app/api/core/respond"
|
||||
"github.com/h44z/wg-portal/internal/app/api/v0/model"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
//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"))),
|
||||
}
|
||||
@@ -46,7 +53,7 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
||||
apiGroup := g.Mount("/config")
|
||||
|
||||
apiGroup.HandleFunc("GET /frontend.js", e.handleConfigJsGet())
|
||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("GET /settings", e.handleSettingsGet())
|
||||
apiGroup.With(e.authenticator.InfoOnly()).HandleFunc("GET /settings", e.handleSettingsGet())
|
||||
}
|
||||
|
||||
// handleConfigJsGet returns a gorm Handler function.
|
||||
@@ -93,11 +100,50 @@ func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
|
||||
// @Router /config/settings [get]
|
||||
func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
respond.JSON(w, http.StatusOK, model.Settings{
|
||||
MailLinkOnly: e.cfg.Mail.LinkOnly,
|
||||
PersistentConfigSupported: e.cfg.Advanced.ConfigStoragePath != "",
|
||||
SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed,
|
||||
ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly,
|
||||
})
|
||||
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,
|
||||
AvailableBackends: []model.SettingsBackendNames{}, // return an empty list instead of null
|
||||
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
|
||||
})
|
||||
} else {
|
||||
respond.JSON(w, http.StatusOK, model.Settings{
|
||||
MailLinkOnly: e.cfg.Mail.LinkOnly,
|
||||
PersistentConfigSupported: e.cfg.Advanced.ConfigStoragePath != "",
|
||||
SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed,
|
||||
ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly,
|
||||
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
|
||||
MinPasswordLength: e.cfg.Auth.MinPasswordLength,
|
||||
AvailableBackends: controllerFn(),
|
||||
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -34,11 +34,11 @@ 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)
|
||||
}
|
||||
@@ -355,6 +355,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 +370,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 +400,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 +415,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 +444,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 +467,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 +513,11 @@ 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
|
||||
}
|
||||
|
@@ -72,6 +72,32 @@ func (h AuthenticationHandler) LoggedIn(scopes ...Scope) func(next http.Handler)
|
||||
}
|
||||
}
|
||||
|
||||
// InfoOnly only checks if the user is logged in and adds the user id to the context.
|
||||
// If the user is not logged in, the context user id is set to domain.CtxUnknownUserId.
|
||||
func (h AuthenticationHandler) InfoOnly() func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := h.session.GetData(r.Context())
|
||||
|
||||
var newContext context.Context
|
||||
|
||||
if !session.LoggedIn {
|
||||
newContext = domain.SetUserInfo(r.Context(), domain.DefaultContextUserInfo())
|
||||
} else {
|
||||
newContext = domain.SetUserInfo(r.Context(), &domain.ContextUserInfo{
|
||||
Id: domain.UserIdentifier(session.UserIdentifier),
|
||||
IsAdmin: session.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
r = r.WithContext(newContext)
|
||||
|
||||
// Continue down the chain to Handler etc
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// UserIdMatch checks if the user id in the session matches the user id in the request. If not, the request is aborted.
|
||||
func (h AuthenticationHandler) UserIdMatch(idParameter string) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
|
@@ -31,6 +31,8 @@ type SessionData struct {
|
||||
OauthProvider string
|
||||
OauthReturnTo string
|
||||
|
||||
WebAuthnData string
|
||||
|
||||
CsrfToken string
|
||||
}
|
||||
|
||||
|
@@ -6,8 +6,17 @@ type Error struct {
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
MailLinkOnly bool `json:"MailLinkOnly"`
|
||||
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
|
||||
SelfProvisioning bool `json:"SelfProvisioning"`
|
||||
ApiAdminOnly bool `json:"ApiAdminOnly"`
|
||||
MailLinkOnly bool `json:"MailLinkOnly"`
|
||||
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
|
||||
SelfProvisioning bool `json:"SelfProvisioning"`
|
||||
ApiAdminOnly bool `json:"ApiAdminOnly"`
|
||||
WebAuthnEnabled bool `json:"WebAuthnEnabled"`
|
||||
MinPasswordLength int `json:"MinPasswordLength"`
|
||||
AvailableBackends []SettingsBackendNames `json:"AvailableBackends"`
|
||||
LoginFormVisible bool `json:"LoginFormVisible"`
|
||||
}
|
||||
|
||||
type SettingsBackendNames struct {
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
@@ -1,6 +1,11 @@
|
||||
package model
|
||||
|
||||
import "github.com/h44z/wg-portal/internal/domain"
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
type LoginProviderInfo struct {
|
||||
Identifier string `json:"Identifier" example:"google"`
|
||||
@@ -39,3 +44,32 @@ type OauthInitiationResponse struct {
|
||||
RedirectUrl string
|
||||
State string
|
||||
}
|
||||
|
||||
type WebAuthnCredentialRequest struct {
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
type WebAuthnCredentialResponse struct {
|
||||
ID string `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
CreatedAt string `json:"CreatedAt"`
|
||||
}
|
||||
|
||||
func NewWebAuthnCredentialResponse(src domain.UserWebauthnCredential) WebAuthnCredentialResponse {
|
||||
return WebAuthnCredentialResponse{
|
||||
ID: src.CredentialIdentifier,
|
||||
Name: src.DisplayName,
|
||||
CreatedAt: src.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
func NewWebAuthnCredentialResponses(src []domain.UserWebauthnCredential) []WebAuthnCredentialResponse {
|
||||
credentials := make([]WebAuthnCredentialResponse, len(src))
|
||||
for i := range src {
|
||||
credentials[i] = NewWebAuthnCredentialResponse(src[i])
|
||||
}
|
||||
// Sort by CreatedAt, newest first
|
||||
slices.SortFunc(credentials, func(i, j WebAuthnCredentialResponse) int {
|
||||
return strings.Compare(i.CreatedAt, j.CreatedAt)
|
||||
})
|
||||
return credentials
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
@@ -11,6 +12,7 @@ type Interface struct {
|
||||
Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0
|
||||
DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface
|
||||
Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any'
|
||||
Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ...
|
||||
PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface
|
||||
PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
|
||||
Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
|
||||
@@ -57,6 +59,7 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
|
||||
Identifier: string(src.Identifier),
|
||||
DisplayName: src.DisplayName,
|
||||
Mode: string(src.Type),
|
||||
Backend: string(src.Backend),
|
||||
PrivateKey: src.PrivateKey,
|
||||
PublicKey: src.PublicKey,
|
||||
Disabled: src.IsDisabled(),
|
||||
@@ -92,6 +95,10 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
|
||||
Filename: src.GetConfigFileName(),
|
||||
}
|
||||
|
||||
if iface.Backend == "" {
|
||||
iface.Backend = config.LocalBackendName // default to local backend
|
||||
}
|
||||
|
||||
if len(peers) > 0 {
|
||||
iface.TotalPeers = len(peers)
|
||||
|
||||
@@ -146,6 +153,7 @@ func NewDomainInterface(src *Interface) *domain.Interface {
|
||||
SaveConfig: src.SaveConfig,
|
||||
DisplayName: src.DisplayName,
|
||||
Type: domain.InterfaceType(src.Mode),
|
||||
Backend: domain.InterfaceBackend(src.Backend),
|
||||
DriverType: "", // currently unused
|
||||
Disabled: nil, // set below
|
||||
DisabledReason: src.DisabledReason,
|
||||
|
@@ -43,6 +43,7 @@ type Peer struct {
|
||||
Identifier string `json:"Identifier" example:"super_nice_peer"` // peer unique identifier
|
||||
DisplayName string `json:"DisplayName"` // a nice display name/ description for the peer
|
||||
UserIdentifier string `json:"UserIdentifier"` // the owner
|
||||
UserDisplayName string `json:"UserDisplayName"` // the owner display name
|
||||
InterfaceIdentifier string `json:"InterfaceIdentifier"` // the interface id
|
||||
Disabled bool `json:"Disabled"` // flag that specifies if the peer is enabled (up) or not (down)
|
||||
DisabledReason string `json:"DisabledReason"` // the reason why the peer has been disabled
|
||||
@@ -80,7 +81,7 @@ type Peer struct {
|
||||
}
|
||||
|
||||
func NewPeer(src *domain.Peer) *Peer {
|
||||
return &Peer{
|
||||
p := &Peer{
|
||||
Identifier: string(src.Identifier),
|
||||
DisplayName: src.DisplayName,
|
||||
UserIdentifier: string(src.UserIdentifier),
|
||||
@@ -111,6 +112,12 @@ func NewPeer(src *domain.Peer) *Peer {
|
||||
PostDown: ConfigOptionFromDomain(src.Interface.PostDown),
|
||||
Filename: src.GetConfigFileName(),
|
||||
}
|
||||
|
||||
if src.User != nil {
|
||||
p.UserDisplayName = src.User.DisplayName()
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func NewPeers(src []domain.Peer) []Peer {
|
||||
@@ -172,13 +179,13 @@ func NewDomainPeer(src *Peer) *domain.Peer {
|
||||
|
||||
type MultiPeerRequest struct {
|
||||
Identifiers []string `json:"Identifiers"`
|
||||
Suffix string `json:"Suffix"`
|
||||
Prefix string `json:"Prefix"`
|
||||
}
|
||||
|
||||
func NewDomainPeerCreationRequest(src *MultiPeerRequest) *domain.PeerCreationRequest {
|
||||
return &domain.PeerCreationRequest{
|
||||
UserIdentifiers: src.Identifiers,
|
||||
Suffix: src.Suffix,
|
||||
Prefix: src.Prefix,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +205,7 @@ func NewPeerStats(enabled bool, src []domain.PeerStatus) *PeerStats {
|
||||
|
||||
for _, srcStat := range src {
|
||||
stats[string(srcStat.PeerId)] = PeerStatData{
|
||||
IsConnected: srcStat.IsConnected(),
|
||||
IsConnected: srcStat.IsConnected,
|
||||
IsPingable: srcStat.IsPingable,
|
||||
LastPing: srcStat.LastPing,
|
||||
BytesReceived: srcStat.BytesReceived,
|
||||
|
@@ -11,6 +11,7 @@ import (
|
||||
type InterfaceServiceInterfaceManagerRepo interface {
|
||||
GetAllInterfacesAndPeers(ctx context.Context) ([]domain.Interface, [][]domain.Peer, error)
|
||||
GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error)
|
||||
PrepareInterface(ctx context.Context) (*domain.Interface, error)
|
||||
CreateInterface(ctx context.Context, in *domain.Interface) (*domain.Interface, error)
|
||||
UpdateInterface(ctx context.Context, in *domain.Interface) (*domain.Interface, []domain.Peer, error)
|
||||
DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error
|
||||
@@ -60,6 +61,19 @@ func (s InterfaceService) GetById(ctx context.Context, id domain.InterfaceIdenti
|
||||
return interfaceData, interfacePeers, nil
|
||||
}
|
||||
|
||||
func (s InterfaceService) Prepare(ctx context.Context) (*domain.Interface, error) {
|
||||
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
interfaceData, err := s.interfaces.PrepareInterface(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return interfaceData, nil
|
||||
}
|
||||
|
||||
func (s InterfaceService) Create(ctx context.Context, iface *domain.Interface) (*domain.Interface, error) {
|
||||
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
||||
return nil, err
|
||||
|
@@ -13,6 +13,7 @@ type PeerServicePeerManagerRepo interface {
|
||||
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error)
|
||||
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
|
||||
GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error)
|
||||
PreparePeer(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Peer, error)
|
||||
CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Peer, error)
|
||||
UpdatePeer(ctx context.Context, peer *domain.Peer) (*domain.Peer, error)
|
||||
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
|
||||
@@ -95,6 +96,19 @@ func (s PeerService) GetById(ctx context.Context, id domain.PeerIdentifier) (*do
|
||||
return peer, nil
|
||||
}
|
||||
|
||||
func (s PeerService) Prepare(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Peer, error) {
|
||||
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
peer, err := s.peers.PreparePeer(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return peer, nil
|
||||
}
|
||||
|
||||
func (s PeerService) Create(ctx context.Context, peer *domain.Peer) (*domain.Peer, error) {
|
||||
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
||||
return nil, err
|
||||
|
@@ -23,8 +23,8 @@ type ProvisioningServicePeerManagerRepo interface {
|
||||
}
|
||||
|
||||
type ProvisioningServiceConfigFileManagerRepo 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 ProvisioningService struct {
|
||||
@@ -96,7 +96,7 @@ func (p ProvisioningService) GetPeerConfig(ctx context.Context, peerId domain.Pe
|
||||
return nil, err
|
||||
}
|
||||
|
||||
peerCfgReader, err := p.configFiles.GetPeerConfig(ctx, peer.Identifier)
|
||||
peerCfgReader, err := p.configFiles.GetPeerConfig(ctx, peer.Identifier, domain.ConfigStyleWgQuick)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func (p ProvisioningService) GetPeerQrPng(ctx context.Context, peerId domain.Pee
|
||||
return nil, err
|
||||
}
|
||||
|
||||
peerCfgQrReader, err := p.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier)
|
||||
peerCfgQrReader, err := p.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier, domain.ConfigStyleWgQuick)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -162,7 +162,11 @@ func (p ProvisioningService) NewPeer(ctx context.Context, req models.Provisionin
|
||||
if req.PresharedKey != "" {
|
||||
peer.PresharedKey = domain.PreSharedKey(req.PresharedKey)
|
||||
}
|
||||
peer.GenerateDisplayName("API")
|
||||
if req.DisplayName == "" {
|
||||
peer.GenerateDisplayName("API")
|
||||
} else {
|
||||
peer.DisplayName = req.DisplayName
|
||||
}
|
||||
|
||||
// save new peer
|
||||
peer, err = p.peers.CreatePeer(ctx, peer)
|
||||
|
@@ -15,6 +15,7 @@ import (
|
||||
type InterfaceEndpointInterfaceService interface {
|
||||
GetAll(context.Context) ([]domain.Interface, [][]domain.Peer, error)
|
||||
GetById(context.Context, domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error)
|
||||
Prepare(context.Context) (*domain.Interface, error)
|
||||
Create(context.Context, *domain.Interface) (*domain.Interface, error)
|
||||
Update(context.Context, domain.InterfaceIdentifier, *domain.Interface) (*domain.Interface, []domain.Peer, error)
|
||||
Delete(context.Context, domain.InterfaceIdentifier) error
|
||||
@@ -49,6 +50,7 @@ func (e InterfaceEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
||||
apiGroup.HandleFunc("GET /all", e.handleAllGet())
|
||||
apiGroup.HandleFunc("GET /by-id/{id}", e.handleByIdGet())
|
||||
|
||||
apiGroup.HandleFunc("GET /prepare", e.handlePrepareGet())
|
||||
apiGroup.HandleFunc("POST /new", e.handleCreatePost())
|
||||
apiGroup.HandleFunc("PUT /by-id/{id}", e.handleUpdatePut())
|
||||
apiGroup.HandleFunc("DELETE /by-id/{id}", e.handleDelete())
|
||||
@@ -112,11 +114,38 @@ func (e InterfaceEndpoint) handleByIdGet() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// handlePrepareGet returns a gorm handler function.
|
||||
//
|
||||
// @ID interfaces_handlePrepareGet
|
||||
// @Tags Interfaces
|
||||
// @Summary Prepare a new interface record.
|
||||
// @Description This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.Interface
|
||||
// @Failure 401 {object} models.Error
|
||||
// @Failure 403 {object} models.Error
|
||||
// @Failure 500 {object} models.Error
|
||||
// @Router /interface/prepare [get]
|
||||
// @Security BasicAuth
|
||||
func (e InterfaceEndpoint) handlePrepareGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
iface, err := e.interfaces.Prepare(r.Context())
|
||||
if err != nil {
|
||||
status, model := ParseServiceError(err)
|
||||
respond.JSON(w, status, model)
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, models.NewInterface(iface, nil))
|
||||
}
|
||||
}
|
||||
|
||||
// handleCreatePost returns a gorm handler function.
|
||||
//
|
||||
// @ID interfaces_handleCreatePost
|
||||
// @Tags Interfaces
|
||||
// @Summary Create a new interface record.
|
||||
// @Description This endpoint creates a new interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).
|
||||
// @Param request body models.Interface true "The interface data."
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.Interface
|
||||
@@ -155,6 +184,7 @@ func (e InterfaceEndpoint) handleCreatePost() http.HandlerFunc {
|
||||
// @ID interfaces_handleUpdatePut
|
||||
// @Tags Interfaces
|
||||
// @Summary Update an interface record.
|
||||
// @Description This endpoint updates an existing interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).
|
||||
// @Param id path string true "The interface identifier."
|
||||
// @Param request body models.Interface true "The interface data."
|
||||
// @Produce json
|
||||
|
@@ -16,6 +16,7 @@ type PeerService interface {
|
||||
GetForInterface(context.Context, domain.InterfaceIdentifier) ([]domain.Peer, error)
|
||||
GetForUser(context.Context, domain.UserIdentifier) ([]domain.Peer, error)
|
||||
GetById(context.Context, domain.PeerIdentifier) (*domain.Peer, error)
|
||||
Prepare(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Peer, error)
|
||||
Create(context.Context, *domain.Peer) (*domain.Peer, error)
|
||||
Update(context.Context, domain.PeerIdentifier, *domain.Peer) (*domain.Peer, error)
|
||||
Delete(context.Context, domain.PeerIdentifier) error
|
||||
@@ -51,6 +52,7 @@ func (e PeerEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
||||
apiGroup.HandleFunc("GET /by-user/{id}", e.handleAllForUserGet())
|
||||
apiGroup.HandleFunc("GET /by-id/{id}", e.handleByIdGet())
|
||||
|
||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("GET /prepare/{id}", e.handlePrepareGet())
|
||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /new", e.handleCreatePost())
|
||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("PUT /by-id/{id}", e.handleUpdatePut())
|
||||
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("DELETE /by-id/{id}", e.handleDelete())
|
||||
@@ -156,12 +158,48 @@ func (e PeerEndpoint) handleByIdGet() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// handlePrepareGet returns a gorm handler function.
|
||||
//
|
||||
// @ID peers_handlePrepareGet
|
||||
// @Tags Peers
|
||||
// @Summary Prepare a new peer record for the given WireGuard interface.
|
||||
// @Description This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.
|
||||
// @Param id path string true "The interface identifier."
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.Peer
|
||||
// @Failure 400 {object} models.Error
|
||||
// @Failure 401 {object} models.Error
|
||||
// @Failure 403 {object} models.Error
|
||||
// @Failure 404 {object} models.Error
|
||||
// @Failure 500 {object} models.Error
|
||||
// @Router /peer/prepare/{id} [get]
|
||||
// @Security BasicAuth
|
||||
func (e PeerEndpoint) handlePrepareGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := request.Path(r, "id")
|
||||
if id == "" {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
models.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
|
||||
return
|
||||
}
|
||||
|
||||
peer, err := e.peers.Prepare(r.Context(), domain.InterfaceIdentifier(id))
|
||||
if err != nil {
|
||||
status, model := ParseServiceError(err)
|
||||
respond.JSON(w, status, model)
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, models.NewPeer(peer))
|
||||
}
|
||||
}
|
||||
|
||||
// handleCreatePost returns a gorm handler function.
|
||||
//
|
||||
// @ID peers_handleCreatePost
|
||||
// @Tags Peers
|
||||
// @Summary Create a new peer record.
|
||||
// @Description Only admins can create new records.
|
||||
// @Description Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).
|
||||
// @Param request body models.Peer true "The peer data."
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.Peer
|
||||
@@ -200,7 +238,7 @@ func (e PeerEndpoint) handleCreatePost() http.HandlerFunc {
|
||||
// @ID peers_handleUpdatePut
|
||||
// @Tags Peers
|
||||
// @Summary Update a peer record.
|
||||
// @Description Only admins can update existing records.
|
||||
// @Description Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).
|
||||
// @Param id path string true "The peer identifier."
|
||||
// @Param request body models.Peer true "The peer data."
|
||||
// @Produce json
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user