mirror of
https://github.com/h44z/wg-portal.git
synced 2025-10-05 07:56:17 +00:00
Compare commits
58 Commits
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 | ||
|
1394be2341 |
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:
|
patch:
|
||||||
update-types:
|
update-types:
|
||||||
- patch
|
- 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
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
# ct lint requires Python 3.x to run following packages:
|
# ct lint requires Python 3.x to run following packages:
|
||||||
# - yamale (https://github.com/23andMe/Yamale)
|
# - yamale (https://github.com/23andMe/Yamale)
|
||||||
# - yamllint (https://github.com/adrienverge/yamllint)
|
# - yamllint (https://github.com/adrienverge/yamllint)
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.x'
|
python-version: '3.x'
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
|
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -110,7 +110,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Download binaries
|
- name: Download binaries
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: binaries
|
name: binaries
|
||||||
|
|
||||||
|
4
.github/workflows/pages.yml
vendored
4
.github/workflows/pages.yml
vendored
@@ -15,11 +15,11 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: 3.x
|
python-version: 3.x
|
||||||
|
|
||||||
|
@@ -20,7 +20,7 @@ RUN npm run build
|
|||||||
######
|
######
|
||||||
# Build backend
|
# Build backend
|
||||||
######
|
######
|
||||||
FROM --platform=${BUILDPLATFORM} golang:1.24-alpine AS builder
|
FROM --platform=${BUILDPLATFORM} golang:1.25-alpine AS builder
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
# Download dependencies
|
# Download dependencies
|
||||||
@@ -50,9 +50,9 @@ COPY --from=builder /build/dist/wg-portal /
|
|||||||
######
|
######
|
||||||
# Final image
|
# Final image
|
||||||
######
|
######
|
||||||
FROM alpine:3.19
|
FROM alpine:3.22
|
||||||
# Install OS-level dependencies
|
# 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
|
# Setup timezone
|
||||||
ENV TZ=UTC
|
ENV TZ=UTC
|
||||||
# Copy binaries
|
# 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
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
a copy of this software and associated documentation files (the
|
a copy of this software and associated documentation files (the
|
||||||
|
16
README.md
16
README.md
@@ -21,17 +21,18 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Self-hosted - the whole application is a single binary
|
* 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
|
* Automatically selects IP from the network pool assigned to the client
|
||||||
* QR-Code for convenient mobile client configuration
|
* QR-Code for convenient mobile client configuration
|
||||||
* Sends email to the client with QR-code and client config
|
* Sends email to the client with QR-code and client config
|
||||||
* Enable / Disable clients seamlessly
|
* Enable / Disable clients seamlessly
|
||||||
* Generation of wg-quick configuration file (`wgX.conf`) if required
|
* 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
|
* IPv6 ready
|
||||||
* Docker ready
|
* Docker ready
|
||||||
* Can be used with existing WireGuard setups
|
* Can be used with existing WireGuard setups
|
||||||
* Support for multiple WireGuard interfaces
|
* Support for multiple WireGuard interfaces
|
||||||
|
* Supports multiple WireGuard backends (wgctrl or MikroTik)
|
||||||
* Peer Expiry Feature
|
* Peer Expiry Feature
|
||||||
* Handles route and DNS settings like wg-quick does
|
* Handles route and DNS settings like wg-quick does
|
||||||
* Exposes Prometheus metrics for monitoring and alerting
|
* Exposes Prometheus metrics for monitoring and alerting
|
||||||
@@ -61,6 +62,17 @@ For the complete documentation visit [wgportal.org](https://wgportal.org).
|
|||||||
|
|
||||||
* MIT License. [MIT](LICENSE.txt) or <https://opensource.org/licenses/MIT>
|
* 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]
|
> [!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).
|
> Since the project was accepted by the Docker-Sponsored Open Source Program, the Docker image location has moved to [wgportal/wg-portal](https://hub.docker.com/r/wgportal/wg-portal).
|
||||||
|
@@ -7,7 +7,7 @@ If you believe you've found a security issue in one of the supported versions of
|
|||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
|---------|--------------------|
|
|---------|--------------------|
|
||||||
| v2.x | :white_check_mark: |
|
| v2.x | :white_check_mark: |
|
||||||
| v1.x | :white_check_mark: |
|
| v1.x | :x: |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
@@ -50,7 +50,8 @@ func main() {
|
|||||||
database, err := adapters.NewSqlRepository(rawDb)
|
database, err := adapters.NewSqlRepository(rawDb)
|
||||||
internal.AssertNoError(err)
|
internal.AssertNoError(err)
|
||||||
|
|
||||||
wireGuard := adapters.NewWireGuardRepository()
|
wireGuard, err := wireguard.NewControllerManager(cfg)
|
||||||
|
internal.AssertNoError(err)
|
||||||
|
|
||||||
wgQuick := adapters.NewWgQuickRepo()
|
wgQuick := adapters.NewWgQuickRepo()
|
||||||
|
|
||||||
@@ -87,6 +88,10 @@ func main() {
|
|||||||
|
|
||||||
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager)
|
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager)
|
||||||
internal.AssertNoError(err)
|
internal.AssertNoError(err)
|
||||||
|
authenticator.StartBackgroundJobs(ctx)
|
||||||
|
|
||||||
|
webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager)
|
||||||
|
internal.AssertNoError(err)
|
||||||
|
|
||||||
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database)
|
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database)
|
||||||
internal.AssertNoError(err)
|
internal.AssertNoError(err)
|
||||||
@@ -124,12 +129,13 @@ func main() {
|
|||||||
apiV0BackendInterfaces := backendV0.NewInterfaceService(cfg, wireGuardManager, cfgFileManager)
|
apiV0BackendInterfaces := backendV0.NewInterfaceService(cfg, wireGuardManager, cfgFileManager)
|
||||||
apiV0BackendPeers := backendV0.NewPeerService(cfg, wireGuardManager, cfgFileManager, mailManager)
|
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)
|
apiV0EndpointAudit := handlersV0.NewAuditEndpoint(cfg, apiV0Auth, auditManager)
|
||||||
apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers)
|
apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers)
|
||||||
apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces)
|
apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces)
|
||||||
apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers)
|
apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers)
|
||||||
apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth)
|
apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard)
|
||||||
apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth)
|
apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth)
|
||||||
|
|
||||||
apiFrontend := handlersV0.NewRestApi(apiV0Session,
|
apiFrontend := handlersV0.NewRestApi(apiV0Session,
|
||||||
|
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,6 +11,24 @@ core:
|
|||||||
create_default_peer: true
|
create_default_peer: true
|
||||||
self_provisioning_allowed: 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:
|
web:
|
||||||
site_title: My WireGuard Server
|
site_title: My WireGuard Server
|
||||||
site_company_name: My Company
|
site_company_name: My Company
|
||||||
@@ -32,6 +50,10 @@ database:
|
|||||||
type: sqlite
|
type: sqlite
|
||||||
dsn: data/sqlite.db
|
dsn: data/sqlite.db
|
||||||
encryption_passphrase: change-this-s3cr3t-encryption-passphrase
|
encryption_passphrase: change-this-s3cr3t-encryption-passphrase
|
||||||
|
|
||||||
|
auth:
|
||||||
|
webauthn:
|
||||||
|
enabled: true
|
||||||
```
|
```
|
||||||
|
|
||||||
## LDAP Authentication and Synchronization
|
## LDAP Authentication and Synchronization
|
||||||
@@ -191,3 +213,5 @@ auth:
|
|||||||
registration_enabled: true
|
registration_enabled: true
|
||||||
log_user_info: 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)).
|
||||||
|
@@ -14,8 +14,9 @@ Configuration examples are available on the [Examples](./examples.md) page.
|
|||||||
```yaml
|
```yaml
|
||||||
core:
|
core:
|
||||||
admin_user: admin@wgportal.local
|
admin_user: admin@wgportal.local
|
||||||
admin_password: wgportal
|
admin_password: wgportal-default
|
||||||
admin_api_token: ""
|
admin_api_token: ""
|
||||||
|
disable_admin_user: false
|
||||||
editable_keys: true
|
editable_keys: true
|
||||||
create_default_peer: false
|
create_default_peer: false
|
||||||
create_default_peer_on_creation: false
|
create_default_peer_on_creation: false
|
||||||
@@ -24,6 +25,9 @@ core:
|
|||||||
self_provisioning_allowed: false
|
self_provisioning_allowed: false
|
||||||
import_existing: true
|
import_existing: true
|
||||||
restore_state: true
|
restore_state: true
|
||||||
|
|
||||||
|
backend:
|
||||||
|
default: local
|
||||||
|
|
||||||
advanced:
|
advanced:
|
||||||
log_level: info
|
log_level: info
|
||||||
@@ -38,6 +42,7 @@ advanced:
|
|||||||
rule_prio_offset: 20000
|
rule_prio_offset: 20000
|
||||||
route_table_offset: 20000
|
route_table_offset: 20000
|
||||||
api_admin_only: true
|
api_admin_only: true
|
||||||
|
limit_additional_user_peers: 0
|
||||||
|
|
||||||
database:
|
database:
|
||||||
debug: false
|
debug: false
|
||||||
@@ -72,6 +77,10 @@ auth:
|
|||||||
oidc: []
|
oidc: []
|
||||||
oauth: []
|
oauth: []
|
||||||
ldap: []
|
ldap: []
|
||||||
|
webauthn:
|
||||||
|
enabled: true
|
||||||
|
min_password_length: 16
|
||||||
|
hide_login_form: false
|
||||||
|
|
||||||
web:
|
web:
|
||||||
listening_address: :8888
|
listening_address: :8888
|
||||||
@@ -97,6 +106,7 @@ webhook:
|
|||||||
|
|
||||||
Below you will find sections like
|
Below you will find sections like
|
||||||
[`core`](#core),
|
[`core`](#core),
|
||||||
|
[`backend`](#backend),
|
||||||
[`advanced`](#advanced),
|
[`advanced`](#advanced),
|
||||||
[`database`](#database),
|
[`database`](#database),
|
||||||
[`statistics`](#statistics),
|
[`statistics`](#statistics),
|
||||||
@@ -118,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.
|
- **Description:** The administrator user. This user will be created as a default admin if it does not yet exist.
|
||||||
|
|
||||||
### `admin_password`
|
### `admin_password`
|
||||||
- **Default:** `wgportal`
|
- **Default:** `wgportal-default`
|
||||||
- **Description:** The administrator password. The default password of `wgportal` should be changed immediately.
|
- **Description:** The administrator password. The default password should be changed immediately!
|
||||||
|
- **Important:** The password should be strong and secure. The minimum password length is specified in [auth.min_password_length](#min_password_length). By default, it is 16 characters.
|
||||||
|
|
||||||
|
### `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`
|
### `admin_api_token`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
@@ -159,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
|
## Advanced
|
||||||
|
|
||||||
Additional or more specialized configuration options for logging and interface creation details.
|
Additional or more specialized configuration options for logging and interface creation details.
|
||||||
@@ -211,6 +295,10 @@ Additional or more specialized configuration options for logging and interface c
|
|||||||
- **Default:** `true`
|
- **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).
|
- **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
|
## Database
|
||||||
@@ -334,9 +422,23 @@ Options for configuring email notifications or sending peer configurations via e
|
|||||||
|
|
||||||
## Auth
|
## 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.
|
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
|
### OIDC
|
||||||
@@ -540,6 +642,8 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
|
|||||||
```text
|
```text
|
||||||
(&(objectClass=organizationalPerson)(mail={{login_identifier}})(!userAccountControl:1.2.840.113556.1.4.803:=2))
|
(&(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`
|
#### `admin_group`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
@@ -580,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
|
## Web
|
||||||
|
|
||||||
The web section contains configuration options for the web server, including the listening address, session management, and CSRF protection.
|
The web section contains configuration options for the web server, including the listening address, session management, and CSRF protection.
|
||||||
@@ -637,18 +751,7 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
|
|||||||
## Webhook
|
## Webhook
|
||||||
|
|
||||||
The webhook section allows you to configure a webhook that is called on certain events in WireGuard Portal.
|
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:
|
Further details can be found in the [usage documentation](../usage/webhooks.md).
|
||||||
```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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `url`
|
### `url`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
|
@@ -403,6 +403,12 @@ definitions:
|
|||||||
type: object
|
type: object
|
||||||
models.ProvisioningRequest:
|
models.ProvisioningRequest:
|
||||||
properties:
|
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:
|
InterfaceIdentifier:
|
||||||
description: InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to.
|
description: InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to.
|
||||||
example: wg0
|
example: wg0
|
||||||
|
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);
|
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>
|
</style>
|
||||||
|
|
||||||
<!-- Hero for landing page -->
|
<!-- Hero for landing page -->
|
||||||
@@ -326,11 +379,34 @@
|
|||||||
|
|
||||||
<div class="md-container">
|
<div class="md-container">
|
||||||
<div class="tx-hero__image">
|
<div class="tx-hero__image">
|
||||||
<img
|
<div>
|
||||||
src="{{config.site_url}}/assets/images/screenshot.png"
|
<img-comparison-slider hover="hover">
|
||||||
alt=""
|
<figure slot="first" class="before">
|
||||||
draggable="false"
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-bs-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link href="/favicon.ico" rel="icon" />
|
<link href="/favicon.ico" rel="icon" />
|
||||||
|
1575
frontend/package-lock.json
generated
1575
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",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
"@kyvg/vue3-notification": "^3.4.1",
|
"@kyvg/vue3-notification": "^3.4.1",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@simplewebauthn/browser": "^13.1.0",
|
||||||
"@vojtechlanka/vue-tags-input": "^3.1.1",
|
"@vojtechlanka/vue-tags-input": "^3.1.1",
|
||||||
"bootstrap": "^5.3.5",
|
"bootstrap": "^5.3.7",
|
||||||
"bootswatch": "^5.3.5",
|
"bootswatch": "^5.3.7",
|
||||||
"flag-icons": "^7.3.2",
|
"flag-icons": "^7.3.2",
|
||||||
"ip-address": "^10.0.1",
|
"ip-address": "^10.0.1",
|
||||||
"is-cidr": "^5.1.1",
|
"is-cidr": "^5.1.1",
|
||||||
@@ -29,6 +30,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
"sass-embedded": "^1.86.3",
|
"sass-embedded": "^1.86.3",
|
||||||
"vite": "6.3.4"
|
"vite": "^6.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -14,6 +14,10 @@ const settings = settingsStore()
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
console.log("Starting WireGuard Portal frontend...");
|
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 sec.LoadSecurityProperties();
|
||||||
await auth.LoadProviders();
|
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(() => {
|
const languageFlag = computed(() => {
|
||||||
// `this` points to the component instance
|
// `this` points to the component instance
|
||||||
let lang = appGlobal.$i18n.locale.toLowerCase();
|
let lang = appGlobal.$i18n.locale.toLowerCase();
|
||||||
@@ -52,6 +63,7 @@ const languageFlag = computed(() => {
|
|||||||
uk: "ua",
|
uk: "ua",
|
||||||
zh: "cn",
|
zh: "cn",
|
||||||
ko: "kr",
|
ko: "kr",
|
||||||
|
es: "es",
|
||||||
|
|
||||||
};
|
};
|
||||||
return "fi-" + (langMap[lang] || lang);
|
return "fi-" + (langMap[lang] || lang);
|
||||||
@@ -116,7 +128,7 @@ const userDisplayName = computed(() => {
|
|||||||
href="#" role="button">{{ userDisplayName }}</a>
|
href="#" role="button">{{ userDisplayName }}</a>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<RouterLink :to="{ name: 'profile' }" class="dropdown-item"><i class="fas fa-user"></i> {{ $t('menu.profile') }}</RouterLink>
|
<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>
|
<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>
|
<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>
|
<a class="dropdown-item" href="#" @click.prevent="auth.Logout"><i class="fas fa-sign-out-alt"></i> {{ $t('menu.logout') }}</a>
|
||||||
@@ -125,6 +137,24 @@ const userDisplayName = computed(() => {
|
|||||||
<div v-if="!auth.IsAuthenticated" class="nav-item">
|
<div v-if="!auth.IsAuthenticated" class="nav-item">
|
||||||
<RouterLink :to="{ name: 'login' }" class="nav-link"><i class="fas fa-sign-in-alt fa-sm fa-fw me-2"></i>{{ $t('menu.login') }}</RouterLink>
|
<RouterLink :to="{ name: 'login' }" class="nav-link"><i class="fas fa-sign-in-alt fa-sm fa-fw me-2"></i>{{ $t('menu.login') }}</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item dropdown" 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +171,7 @@ const userDisplayName = computed(() => {
|
|||||||
<div class="col-6 text-end">
|
<div class="col-6 text-end">
|
||||||
<div :aria-label="$t('menu.lang')" class="btn-group" role="group">
|
<div :aria-label="$t('menu.lang')" class="btn-group" role="group">
|
||||||
<div 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>
|
data-bs-toggle="dropdown" type="button"><span :class="languageFlag" class="fi"></span></button>
|
||||||
<div aria-labelledby="btnGroupDrop3" class="dropdown-menu" style="">
|
<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>
|
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('de')"><span class="fi fi-de"></span> Deutsch</a>
|
||||||
@@ -153,6 +183,7 @@ const userDisplayName = computed(() => {
|
|||||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('uk')"><span class="fi fi-ua"></span> Українська</a>
|
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('uk')"><span class="fi fi-ua"></span> Українська</a>
|
||||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('vi')"><span class="fi fi-vi"></span> Tiếng Việt</a>
|
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('vi')"><span class="fi fi-vi"></span> Tiếng Việt</a>
|
||||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('zh')"><span class="fi fi-cn"></span> 中文</a>
|
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('zh')"><span class="fi fi-cn"></span> 中文</a>
|
||||||
|
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('es')"><span class="fi fi-es"></span> Español</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,4 +194,31 @@ const userDisplayName = computed(() => {
|
|||||||
</footer>
|
</footer>
|
||||||
</template>
|
</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);
|
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 */
|
/* the styles if a tag is invalid */
|
||||||
.vue-tags-input .ti-tag.ti-invalid {
|
.vue-tags-input .ti-tag.ti-invalid {
|
||||||
background-color: #e88a74;
|
background-color: #e88a74;
|
||||||
|
@@ -10,11 +10,13 @@ import isCidr from "is-cidr";
|
|||||||
import {isIP} from 'is-ip';
|
import {isIP} from 'is-ip';
|
||||||
import { freshInterface } from '@/helpers/models';
|
import { freshInterface } from '@/helpers/models';
|
||||||
import {peerStore} from "@/stores/peers";
|
import {peerStore} from "@/stores/peers";
|
||||||
|
import {settingsStore} from "@/stores/settings";
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const interfaces = interfaceStore()
|
const interfaces = interfaceStore()
|
||||||
const peers = peerStore()
|
const peers = peerStore()
|
||||||
|
const settings = settingsStore()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
interfaceId: String,
|
interfaceId: String,
|
||||||
@@ -48,6 +50,26 @@ const currentTags = ref({
|
|||||||
PeerDefDnsSearch: ""
|
PeerDefDnsSearch: ""
|
||||||
})
|
})
|
||||||
const formData = ref(freshInterface())
|
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
|
// functions
|
||||||
|
|
||||||
@@ -61,6 +83,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
|
|||||||
formData.value.Identifier = interfaces.Prepared.Identifier
|
formData.value.Identifier = interfaces.Prepared.Identifier
|
||||||
formData.value.DisplayName = interfaces.Prepared.DisplayName
|
formData.value.DisplayName = interfaces.Prepared.DisplayName
|
||||||
formData.value.Mode = interfaces.Prepared.Mode
|
formData.value.Mode = interfaces.Prepared.Mode
|
||||||
|
formData.value.Backend = interfaces.Prepared.Backend
|
||||||
|
|
||||||
formData.value.PublicKey = interfaces.Prepared.PublicKey
|
formData.value.PublicKey = interfaces.Prepared.PublicKey
|
||||||
formData.value.PrivateKey = interfaces.Prepared.PrivateKey
|
formData.value.PrivateKey = interfaces.Prepared.PrivateKey
|
||||||
@@ -99,6 +122,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
|
|||||||
formData.value.Identifier = selectedInterface.value.Identifier
|
formData.value.Identifier = selectedInterface.value.Identifier
|
||||||
formData.value.DisplayName = selectedInterface.value.DisplayName
|
formData.value.DisplayName = selectedInterface.value.DisplayName
|
||||||
formData.value.Mode = selectedInterface.value.Mode
|
formData.value.Mode = selectedInterface.value.Mode
|
||||||
|
formData.value.Backend = selectedInterface.value.Backend
|
||||||
|
|
||||||
formData.value.PublicKey = selectedInterface.value.PublicKey
|
formData.value.PublicKey = selectedInterface.value.PublicKey
|
||||||
formData.value.PrivateKey = selectedInterface.value.PrivateKey
|
formData.value.PrivateKey = selectedInterface.value.PrivateKey
|
||||||
@@ -237,6 +261,8 @@ function handleChangePeerDefDnsSearch(tags) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (isSaving.value) return
|
||||||
|
isSaving.value = true
|
||||||
try {
|
try {
|
||||||
if (props.interfaceId!=='#NEW#') {
|
if (props.interfaceId!=='#NEW#') {
|
||||||
await interfaces.UpdateInterface(selectedInterface.value.Identifier, formData.value)
|
await interfaces.UpdateInterface(selectedInterface.value.Identifier, formData.value)
|
||||||
@@ -251,6 +277,8 @@ async function save() {
|
|||||||
text: e.toString(),
|
text: e.toString(),
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +287,8 @@ async function applyPeerDefaults() {
|
|||||||
return; // do nothing for new interfaces
|
return; // do nothing for new interfaces
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isApplyingDefaults.value) return
|
||||||
|
isApplyingDefaults.value = true
|
||||||
try {
|
try {
|
||||||
await interfaces.ApplyPeerDefaults(selectedInterface.value.Identifier, formData.value)
|
await interfaces.ApplyPeerDefaults(selectedInterface.value.Identifier, formData.value)
|
||||||
|
|
||||||
@@ -276,10 +306,14 @@ async function applyPeerDefaults() {
|
|||||||
text: e.toString(),
|
text: e.toString(),
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
isApplyingDefaults.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del() {
|
async function del() {
|
||||||
|
if (isDeleting.value) return
|
||||||
|
isDeleting.value = true
|
||||||
try {
|
try {
|
||||||
await interfaces.DeleteInterface(selectedInterface.value.Identifier)
|
await interfaces.DeleteInterface(selectedInterface.value.Identifier)
|
||||||
close()
|
close()
|
||||||
@@ -290,6 +324,8 @@ async function del() {
|
|||||||
text: e.toString(),
|
text: e.toString(),
|
||||||
type: 'error',
|
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>
|
<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">
|
<input v-model="formData.Identifier" class="form-control" :placeholder="$t('modals.interface-edit.identifier.placeholder')" type="text">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="row">
|
||||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.mode.label') }}</label>
|
<div class="form-group col-md-6">
|
||||||
<select v-model="formData.Mode" class="form-select">
|
<label class="form-label mt-4">{{ $t('modals.interface-edit.mode.label') }}</label>
|
||||||
<option value="server">{{ $t('modals.interface-edit.mode.server') }}</option>
|
<select v-model="formData.Mode" class="form-select">
|
||||||
<option value="client">{{ $t('modals.interface-edit.mode.client') }}</option>
|
<option value="server">{{ $t('modals.interface-edit.mode.server') }}</option>
|
||||||
<option value="any">{{ $t('modals.interface-edit.mode.any') }}</option>
|
<option value="client">{{ $t('modals.interface-edit.mode.client') }}</option>
|
||||||
</select>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.display-name.label') }}</label>
|
<label class="form-label mt-4">{{ $t('modals.interface-edit.display-name.label') }}</label>
|
||||||
@@ -385,12 +430,14 @@ async function del() {
|
|||||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.mtu.label') }}</label>
|
<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">
|
<input v-model="formData.Mtu" class="form-control" :placeholder="$t('modals.interface-edit.mtu.placeholder')" type="number">
|
||||||
</div>
|
</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>
|
<label class="form-label mt-4">{{ $t('modals.interface-edit.firewall-mark.label') }}</label>
|
||||||
<input v-model="formData.FirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number">
|
<input v-model="formData.FirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group col-md-6" v-else>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row" v-if="formData.Backend==='local'">
|
||||||
<div class="form-group col-md-6">
|
<div class="form-group col-md-6">
|
||||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.routing-table.label') }}</label>
|
<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">
|
<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>
|
||||||
<fieldset v-if="props.interfaceId!=='#NEW#'" class="text-end">
|
<fieldset v-if="props.interfaceId!=='#NEW#'" class="text-end">
|
||||||
<hr class="mt-4">
|
<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>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex-fill text-start">
|
<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>
|
</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>
|
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@@ -73,6 +73,8 @@ const currentTags = ref({
|
|||||||
DnsSearch: ""
|
DnsSearch: ""
|
||||||
})
|
})
|
||||||
const formData = ref(freshPeer())
|
const formData = ref(freshPeer())
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const isDeleting = ref(false)
|
||||||
|
|
||||||
// functions
|
// functions
|
||||||
|
|
||||||
@@ -270,6 +272,8 @@ function handleChangeDnsSearch(tags) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (isSaving.value) return
|
||||||
|
isSaving.value = true
|
||||||
try {
|
try {
|
||||||
if (props.peerId !== '#NEW#') {
|
if (props.peerId !== '#NEW#') {
|
||||||
await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value)
|
await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value)
|
||||||
@@ -278,26 +282,30 @@ async function save() {
|
|||||||
}
|
}
|
||||||
close()
|
close()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// console.log(e)
|
|
||||||
notify({
|
notify({
|
||||||
title: "Failed to save peer!",
|
title: "Failed to save peer!",
|
||||||
text: e.toString(),
|
text: e.toString(),
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del() {
|
async function del() {
|
||||||
|
if (isDeleting.value) return
|
||||||
|
isDeleting.value = true
|
||||||
try {
|
try {
|
||||||
await peers.DeletePeer(selectedPeer.value.Identifier)
|
await peers.DeletePeer(selectedPeer.value.Identifier)
|
||||||
close()
|
close()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// console.log(e)
|
|
||||||
notify({
|
notify({
|
||||||
title: "Failed to delete peer!",
|
title: "Failed to delete peer!",
|
||||||
text: e.toString(),
|
text: e.toString(),
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
isDeleting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +358,7 @@ async function del() {
|
|||||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint.placeholder')"
|
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.endpoint.placeholder')"
|
||||||
v-model="formData.Endpoint.Value">
|
v-model="formData.Endpoint.Value">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group" v-if="selectedInterface.Mode !== 'client'">
|
||||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.ip.label') }}</label>
|
<label class="form-label mt-4">{{ $t('modals.peer-edit.ip.label') }}</label>
|
||||||
<vue-tags-input class="form-control" v-model="currentTags.Addresses"
|
<vue-tags-input class="form-control" v-model="currentTags.Addresses"
|
||||||
:tags="formData.Addresses.map(str => ({ text: str }))"
|
:tags="formData.Addresses.map(str => ({ text: str }))"
|
||||||
@@ -470,10 +478,15 @@ async function del() {
|
|||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex-fill text-start">
|
<div class="flex-fill text-start">
|
||||||
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{
|
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del" :disabled="isDeleting">
|
||||||
$t('general.delete') }}</button>
|
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||||
|
{{ $t('general.delete') }}
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@@ -38,6 +38,7 @@ function freshForm() {
|
|||||||
|
|
||||||
const currentTag = ref("")
|
const currentTag = ref("")
|
||||||
const formData = ref(freshForm())
|
const formData = ref(freshForm())
|
||||||
|
const isSaving = ref(false)
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
if (!props.visible) {
|
if (!props.visible) {
|
||||||
@@ -60,12 +61,15 @@ function handleChangeUserIdentifiers(tags) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (isSaving.value) return
|
||||||
|
isSaving.value = true
|
||||||
if (formData.value.Identifiers.length === 0) {
|
if (formData.value.Identifiers.length === 0) {
|
||||||
notify({
|
notify({
|
||||||
title: "Missing Identifiers",
|
title: "Missing Identifiers",
|
||||||
text: "At least one identifier is required to create a new peer.",
|
text: "At least one identifier is required to create a new peer.",
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
|
isSaving.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +83,8 @@ async function save() {
|
|||||||
text: e.toString(),
|
text: e.toString(),
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +114,10 @@ async function save() {
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<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>
|
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@@ -50,7 +50,7 @@ const selectedStats = computed(() => {
|
|||||||
|
|
||||||
if (!s) {
|
if (!s) {
|
||||||
if (!!props.peerId || props.peerId.length) {
|
if (!!props.peerId || props.peerId.length) {
|
||||||
p = profile.Statistics(props.peerId)
|
s = profile.Statistics(props.peerId)
|
||||||
} else {
|
} else {
|
||||||
s = freshStats() // dummy stats to avoid 'undefined' exceptions
|
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) => {
|
watch(() => props.visible, async (newValue, oldValue) => {
|
||||||
if (oldValue === false && newValue === true) { // if modal is shown
|
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
|
configString.value = peers.configuration
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
watch(() => configStyle.value, async () => {
|
||||||
|
await peers.LoadPeerConfig(selectedPeer.value.Identifier, configStyle.value)
|
||||||
|
configString.value = peers.configuration
|
||||||
|
})
|
||||||
|
|
||||||
function download() {
|
function download() {
|
||||||
// credit: https://www.bitdegree.org/learn/javascript-download
|
// credit: https://www.bitdegree.org/learn/javascript-download
|
||||||
@@ -103,7 +109,7 @@ function download() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function email() {
|
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({
|
notify({
|
||||||
title: "Failed to send mail with peer configuration!",
|
title: "Failed to send mail with peer configuration!",
|
||||||
text: e.toString(),
|
text: e.toString(),
|
||||||
@@ -114,7 +120,7 @@ function email() {
|
|||||||
|
|
||||||
function ConfigQrUrl() {
|
function ConfigQrUrl() {
|
||||||
if (props.peerId.length) {
|
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 ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -124,6 +130,15 @@ function ConfigQrUrl() {
|
|||||||
<template>
|
<template>
|
||||||
<Modal :title="title" :visible="visible" @close="close">
|
<Modal :title="title" :visible="visible" @close="close">
|
||||||
<template #default>
|
<template #default>
|
||||||
|
<div class="d-flex justify-content-end align-items-center mb-1" v-if="selectedInterface.Mode !== 'client'">
|
||||||
|
<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" id="peerInformation">
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
@@ -136,20 +151,28 @@ function ConfigQrUrl() {
|
|||||||
data-bs-parent="#peerInformation" style="">
|
data-bs-parent="#peerInformation" style="">
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-8">
|
<div :class="{ 'col-md-8': selectedInterface.Mode !== 'client', 'col-md-12': selectedInterface.Mode !== 'server' }" class="col-md-8">
|
||||||
<ul>
|
<ul>
|
||||||
<li>{{ $t('modals.peer-view.identifier') }}: {{ selectedPeer.PublicKey }}</li>
|
<li v-if="selectedInterface.Mode !== 'client'"><strong>{{ $t('modals.peer-view.identifier') }}</strong>: {{ selectedPeer.PublicKey }}</li>
|
||||||
<li>{{ $t('modals.peer-view.ip') }}: <span v-for="ip in selectedPeer.Addresses" :key="ip"
|
<li v-if="selectedInterface.Mode !== 'server'"><strong>{{ $t('modals.peer-view.endpoint-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>
|
class="badge rounded-pill bg-light">{{ ip }}</span></li>
|
||||||
<li>{{ $t('modals.peer-view.user') }}: {{ selectedPeer.UserIdentifier }}</li>
|
<li v-if="selectedInterface.Mode === 'server'"><strong>{{ $t('modals.peer-view.extra-allowed-ip') }}</strong>: <span v-for="ip in selectedPeer.ExtraAllowedIPs" :key="ip"
|
||||||
<li v-if="selectedPeer.Notes">{{ $t('modals.peer-view.notes') }}: {{ selectedPeer.Notes }}</li>
|
class="badge rounded-pill bg-light">{{ ip }}</span></li>
|
||||||
<li v-if="selectedPeer.ExpiresAt">{{ $t('modals.peer-view.expiry-status') }}: {{
|
<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>
|
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>
|
selectedPeer.DisabledReason }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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">
|
<img class="config-qr-img" :src="ConfigQrUrl()" loading="lazy" alt="Configuration QR Code">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +207,7 @@ function ConfigQrUrl() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<h2 class="accordion-header" id="headingConfig">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||||
data-bs-target="#collapseConfig" aria-expanded="false" aria-controls="collapseConfig">
|
data-bs-target="#collapseConfig" aria-expanded="false" aria-controls="collapseConfig">
|
||||||
@@ -202,9 +225,9 @@ function ConfigQrUrl() {
|
|||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex-fill text-start">
|
<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>
|
$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>
|
$t('modals.peer-view.button-email') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<button @click.prevent="close" type="button" class="btn btn-secondary">{{ $t('general.close') }}</button>
|
<button @click.prevent="close" type="button" class="btn btn-secondary">{{ $t('general.close') }}</button>
|
||||||
@@ -213,6 +236,14 @@ function ConfigQrUrl() {
|
|||||||
</template>
|
</template>
|
||||||
</Modal></template>
|
</Modal></template>
|
||||||
|
|
||||||
<style>.config-qr-img {
|
<style>
|
||||||
|
.config-qr-img {
|
||||||
max-width: 100%;
|
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 { useI18n } from 'vue-i18n';
|
||||||
import { notify } from "@kyvg/vue3-notification";
|
import { notify } from "@kyvg/vue3-notification";
|
||||||
import {freshUser} from "@/helpers/models";
|
import {freshUser} from "@/helpers/models";
|
||||||
|
import {settingsStore} from "@/stores/settings";
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const users = userStore()
|
const users = userStore()
|
||||||
|
const settings = settingsStore()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
userId: String,
|
userId: String,
|
||||||
@@ -32,6 +34,32 @@ const title = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const formData = ref(freshUser())
|
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
|
// functions
|
||||||
|
|
||||||
@@ -63,6 +91,8 @@ function close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (isSaving.value) return
|
||||||
|
isSaving.value = true
|
||||||
try {
|
try {
|
||||||
if (props.userId!=='#NEW#') {
|
if (props.userId!=='#NEW#') {
|
||||||
await users.UpdateUser(selectedUser.value.Identifier, formData.value)
|
await users.UpdateUser(selectedUser.value.Identifier, formData.value)
|
||||||
@@ -76,10 +106,14 @@ async function save() {
|
|||||||
text: e.toString(),
|
text: e.toString(),
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del() {
|
async function del() {
|
||||||
|
if (isDeleting.value) return
|
||||||
|
isDeleting.value = true
|
||||||
try {
|
try {
|
||||||
await users.DeleteUser(selectedUser.value.Identifier)
|
await users.DeleteUser(selectedUser.value.Identifier)
|
||||||
close()
|
close()
|
||||||
@@ -89,6 +123,8 @@ async function del() {
|
|||||||
text: e.toString(),
|
text: e.toString(),
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
isDeleting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +145,8 @@ async function del() {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="formData.Source==='db'" class="form-group">
|
<div v-if="formData.Source==='db'" class="form-group">
|
||||||
<label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label>
|
<label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label>
|
||||||
<input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :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>
|
<small v-if="props.userId!=='#NEW#'" id="passwordHelp" class="form-text text-muted">{{ $t('modals.user-edit.password.description') }}</small>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -166,9 +203,15 @@ async function del() {
|
|||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex-fill text-start">
|
<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>
|
</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>
|
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@@ -55,6 +55,8 @@ const title = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const formData = ref(freshPeer())
|
const formData = ref(freshPeer())
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const isDeleting = ref(false)
|
||||||
|
|
||||||
// functions
|
// functions
|
||||||
|
|
||||||
@@ -163,6 +165,8 @@ function close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (isSaving.value) return
|
||||||
|
isSaving.value = true
|
||||||
try {
|
try {
|
||||||
if (props.peerId !== '#NEW#') {
|
if (props.peerId !== '#NEW#') {
|
||||||
await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value)
|
await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value)
|
||||||
@@ -171,26 +175,30 @@ async function save() {
|
|||||||
}
|
}
|
||||||
close()
|
close()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// console.log(e)
|
|
||||||
notify({
|
notify({
|
||||||
title: "Failed to save peer!",
|
title: "Failed to save peer!",
|
||||||
text: e.toString(),
|
text: e.toString(),
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del() {
|
async function del() {
|
||||||
|
if (isDeleting.value) return
|
||||||
|
isDeleting.value = true
|
||||||
try {
|
try {
|
||||||
await peers.DeletePeer(selectedPeer.value.Identifier)
|
await peers.DeletePeer(selectedPeer.value.Identifier)
|
||||||
close()
|
close()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// console.log(e)
|
|
||||||
notify({
|
notify({
|
||||||
title: "Failed to delete peer!",
|
title: "Failed to delete peer!",
|
||||||
text: e.toString(),
|
text: e.toString(),
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
isDeleting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,10 +291,15 @@ async function del() {
|
|||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex-fill text-start">
|
<div class="flex-fill text-start">
|
||||||
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{
|
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del" :disabled="isDeleting">
|
||||||
$t('general.delete') }}</button>
|
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||||
|
{{ $t('general.delete') }}
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@@ -5,6 +5,7 @@ export function freshInterface() {
|
|||||||
DisplayName: "",
|
DisplayName: "",
|
||||||
Identifier: "",
|
Identifier: "",
|
||||||
Mode: "server",
|
Mode: "server",
|
||||||
|
Backend: "local",
|
||||||
|
|
||||||
PublicKey: "",
|
PublicKey: "",
|
||||||
PrivateKey: "",
|
PrivateKey: "",
|
||||||
@@ -52,6 +53,7 @@ export function freshPeer() {
|
|||||||
Identifier: "",
|
Identifier: "",
|
||||||
DisplayName: "",
|
DisplayName: "",
|
||||||
UserIdentifier: "",
|
UserIdentifier: "",
|
||||||
|
UserDisplayName: "",
|
||||||
InterfaceIdentifier: "",
|
InterfaceIdentifier: "",
|
||||||
Disabled: false,
|
Disabled: false,
|
||||||
ExpiresAt: null,
|
ExpiresAt: null,
|
||||||
|
@@ -8,6 +8,7 @@ import ru from './translations/ru.json';
|
|||||||
import uk from './translations/uk.json';
|
import uk from './translations/uk.json';
|
||||||
import vi from './translations/vi.json';
|
import vi from './translations/vi.json';
|
||||||
import zh from './translations/zh.json';
|
import zh from './translations/zh.json';
|
||||||
|
import es from './translations/es.json';
|
||||||
|
|
||||||
import {createI18n} from "vue-i18n";
|
import {createI18n} from "vue-i18n";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ const i18n = createI18n({
|
|||||||
"uk": uk,
|
"uk": uk,
|
||||||
"vi": vi,
|
"vi": vi,
|
||||||
"zh": zh,
|
"zh": zh,
|
||||||
|
"es": es,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -29,7 +29,8 @@
|
|||||||
"label": "Passwort",
|
"label": "Passwort",
|
||||||
"placeholder": "Bitte geben Sie Ihr Passwort ein"
|
"placeholder": "Bitte geben Sie Ihr Passwort ein"
|
||||||
},
|
},
|
||||||
"button": "Anmelden"
|
"button": "Anmelden",
|
||||||
|
"button-webauthn": "Passkey verwenden"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@@ -101,7 +102,9 @@
|
|||||||
},
|
},
|
||||||
"interface": {
|
"interface": {
|
||||||
"headline": "Schnittstellenstatus für",
|
"headline": "Schnittstellenstatus für",
|
||||||
"mode": "Modus",
|
"backend": "Backend",
|
||||||
|
"unknown-backend": "Unbekannt",
|
||||||
|
"wrong-backend": "Ungültiges Backend, das lokale WireGuard Backend wird stattdessen verwendet!",
|
||||||
"key": "Öffentlicher Schlüssel",
|
"key": "Öffentlicher Schlüssel",
|
||||||
"endpoint": "Öffentlicher Endpunkt",
|
"endpoint": "Öffentlicher Endpunkt",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
@@ -188,6 +191,35 @@
|
|||||||
"button-enable-title": "Aktivieren Sie die API, dadurch wird ein neuer Token generiert.",
|
"button-enable-title": "Aktivieren Sie die API, dadurch wird ein neuer Token generiert.",
|
||||||
"button-enable-text": "API aktivieren",
|
"button-enable-text": "API aktivieren",
|
||||||
"api-link": "API Dokumentation"
|
"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": {
|
"audit": {
|
||||||
@@ -266,7 +298,8 @@
|
|||||||
"password": {
|
"password": {
|
||||||
"label": "Passwort",
|
"label": "Passwort",
|
||||||
"placeholder": "Ein super geheimes Passwort",
|
"placeholder": "Ein super geheimes Passwort",
|
||||||
"description": "Lassen Sie dieses Feld leer, um das aktuelle Passwort beizubehalten."
|
"description": "Lassen Sie dieses Feld leer, um das aktuelle Passwort beizubehalten.",
|
||||||
|
"too-weak": "Das Passwort entspricht nicht den Sicherheitsanforderungen."
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"label": "E-Mail",
|
"label": "E-Mail",
|
||||||
@@ -326,6 +359,11 @@
|
|||||||
"client": "Client-Modus",
|
"client": "Client-Modus",
|
||||||
"any": "Unbekannter 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": {
|
"display-name": {
|
||||||
"label": "Anzeigename",
|
"label": "Anzeigename",
|
||||||
"placeholder": "Der beschreibende Name für die Schnittstelle"
|
"placeholder": "Der beschreibende Name für die Schnittstelle"
|
||||||
@@ -423,6 +461,8 @@
|
|||||||
"section-config": "Konfiguration",
|
"section-config": "Konfiguration",
|
||||||
"identifier": "Kennung",
|
"identifier": "Kennung",
|
||||||
"ip": "IP-Adressen",
|
"ip": "IP-Adressen",
|
||||||
|
"allowed-ip": "Erlaubte IP-Adressen",
|
||||||
|
"extra-allowed-ip": "Serverseitig erlaubte IP-Adressen",
|
||||||
"user": "Zugeordneter Benutzer",
|
"user": "Zugeordneter Benutzer",
|
||||||
"notes": "Notizen",
|
"notes": "Notizen",
|
||||||
"expiry-status": "Läuft ab am",
|
"expiry-status": "Läuft ab am",
|
||||||
@@ -435,8 +475,11 @@
|
|||||||
"handshake": "Letzter Handshake",
|
"handshake": "Letzter Handshake",
|
||||||
"connected-since": "Verbunden seit",
|
"connected-since": "Verbunden seit",
|
||||||
"endpoint": "Endpunkt",
|
"endpoint": "Endpunkt",
|
||||||
|
"endpoint-key": "Öffentlicher Endpunkt-Schlüssel",
|
||||||
|
"keepalive": "Persistentes Keepalive",
|
||||||
"button-download": "Konfiguration herunterladen",
|
"button-download": "Konfiguration herunterladen",
|
||||||
"button-email": "Konfiguration per E-Mail senden"
|
"button-email": "Konfiguration per E-Mail senden",
|
||||||
|
"style-label": "Konfigurationsformat"
|
||||||
},
|
},
|
||||||
"peer-edit": {
|
"peer-edit": {
|
||||||
"headline-edit-peer": "Peer bearbeiten:",
|
"headline-edit-peer": "Peer bearbeiten:",
|
||||||
|
@@ -29,7 +29,8 @@
|
|||||||
"label": "Password",
|
"label": "Password",
|
||||||
"placeholder": "Please enter your password"
|
"placeholder": "Please enter your password"
|
||||||
},
|
},
|
||||||
"button": "Sign in"
|
"button": "Sign in",
|
||||||
|
"button-webauthn": "Use Passkey"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@@ -101,7 +102,9 @@
|
|||||||
},
|
},
|
||||||
"interface": {
|
"interface": {
|
||||||
"headline": "Interface status for",
|
"headline": "Interface status for",
|
||||||
"mode": "mode",
|
"backend": "Backend",
|
||||||
|
"unknown-backend": "Unknown",
|
||||||
|
"wrong-backend": "Invalid backend, using local WireGuard backend instead!",
|
||||||
"key": "Public Key",
|
"key": "Public Key",
|
||||||
"endpoint": "Public Endpoint",
|
"endpoint": "Public Endpoint",
|
||||||
"port": "Listening Port",
|
"port": "Listening Port",
|
||||||
@@ -188,6 +191,35 @@
|
|||||||
"button-enable-title": "Enable API, this will generate a new token.",
|
"button-enable-title": "Enable API, this will generate a new token.",
|
||||||
"button-enable-text": "Enable API",
|
"button-enable-text": "Enable API",
|
||||||
"api-link": "API Documentation"
|
"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": {
|
"audit": {
|
||||||
@@ -266,7 +298,8 @@
|
|||||||
"password": {
|
"password": {
|
||||||
"label": "Password",
|
"label": "Password",
|
||||||
"placeholder": "A super secret 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": {
|
"email": {
|
||||||
"label": "Email",
|
"label": "Email",
|
||||||
@@ -326,6 +359,11 @@
|
|||||||
"client": "Client Mode",
|
"client": "Client Mode",
|
||||||
"any": "Unknown 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": {
|
"display-name": {
|
||||||
"label": "Display Name",
|
"label": "Display Name",
|
||||||
"placeholder": "The descriptive name for the interface"
|
"placeholder": "The descriptive name for the interface"
|
||||||
@@ -424,6 +462,8 @@
|
|||||||
"section-config": "Configuration",
|
"section-config": "Configuration",
|
||||||
"identifier": "Identifier",
|
"identifier": "Identifier",
|
||||||
"ip": "IP Addresses",
|
"ip": "IP Addresses",
|
||||||
|
"allowed-ip": "Allowed IP Addresses",
|
||||||
|
"extra-allowed-ip": "Server Side Allowed IP Addresses",
|
||||||
"user": "Associated User",
|
"user": "Associated User",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"expiry-status": "Expires At",
|
"expiry-status": "Expires At",
|
||||||
@@ -436,8 +476,11 @@
|
|||||||
"handshake": "Last Handshake",
|
"handshake": "Last Handshake",
|
||||||
"connected-since": "Connected since",
|
"connected-since": "Connected since",
|
||||||
"endpoint": "Endpoint",
|
"endpoint": "Endpoint",
|
||||||
|
"endpoint-key": "Endpoint Public Key",
|
||||||
|
"keepalive": "Persistent Keepalive",
|
||||||
"button-download": "Download configuration",
|
"button-download": "Download configuration",
|
||||||
"button-email": "Send configuration via E-Mail"
|
"button-email": "Send configuration via E-Mail",
|
||||||
|
"style-label": "Configuration Style"
|
||||||
},
|
},
|
||||||
"peer-edit": {
|
"peer-edit": {
|
||||||
"headline-edit-peer": "Edit peer:",
|
"headline-edit-peer": "Edit peer:",
|
||||||
|
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": {
|
"interface": {
|
||||||
"headline": "État de l'interface pour",
|
"headline": "État de l'interface pour",
|
||||||
"mode": "mode",
|
"backend": "backend",
|
||||||
"key": "Clé publique",
|
"key": "Clé publique",
|
||||||
"endpoint": "Point de terminaison public",
|
"endpoint": "Point de terminaison public",
|
||||||
"port": "Port d'écoute",
|
"port": "Port d'écoute",
|
||||||
|
@@ -100,7 +100,7 @@
|
|||||||
},
|
},
|
||||||
"interface": {
|
"interface": {
|
||||||
"headline": "인터페이스 상태:",
|
"headline": "인터페이스 상태:",
|
||||||
"mode": "모드",
|
"backend": "백엔드",
|
||||||
"key": "공개 키",
|
"key": "공개 키",
|
||||||
"endpoint": "공개 엔드포인트",
|
"endpoint": "공개 엔드포인트",
|
||||||
"port": "수신 포트",
|
"port": "수신 포트",
|
||||||
|
@@ -101,7 +101,7 @@
|
|||||||
},
|
},
|
||||||
"interface": {
|
"interface": {
|
||||||
"headline": "Status da interface para",
|
"headline": "Status da interface para",
|
||||||
"mode": "modo",
|
"mode": "backend",
|
||||||
"key": "Chave Pública",
|
"key": "Chave Pública",
|
||||||
"endpoint": "Endpoint Público",
|
"endpoint": "Endpoint Público",
|
||||||
"port": "Porta de Escuta",
|
"port": "Porta de Escuta",
|
||||||
|
@@ -99,7 +99,7 @@
|
|||||||
},
|
},
|
||||||
"interface": {
|
"interface": {
|
||||||
"headline": "Статус интерфейса для",
|
"headline": "Статус интерфейса для",
|
||||||
"mode": "режим",
|
"backend": "бэкэнд",
|
||||||
"key": "Публичный ключ",
|
"key": "Публичный ключ",
|
||||||
"endpoint": "Публичная конечная точка",
|
"endpoint": "Публичная конечная точка",
|
||||||
"port": "Порт прослушивания",
|
"port": "Порт прослушивания",
|
||||||
|
@@ -99,7 +99,7 @@
|
|||||||
},
|
},
|
||||||
"interface": {
|
"interface": {
|
||||||
"headline": "Статус інтерфейсу для",
|
"headline": "Статус інтерфейсу для",
|
||||||
"mode": "режим",
|
"backend": "бекенд",
|
||||||
"key": "Публічний ключ",
|
"key": "Публічний ключ",
|
||||||
"endpoint": "Публічна кінцева точка",
|
"endpoint": "Публічна кінцева точка",
|
||||||
"port": "Порт прослуховування",
|
"port": "Порт прослуховування",
|
||||||
|
@@ -98,7 +98,7 @@
|
|||||||
},
|
},
|
||||||
"interface": {
|
"interface": {
|
||||||
"headline": "Trạng thái giao diện cho",
|
"headline": "Trạng thái giao diện cho",
|
||||||
"mode": "chế độ",
|
"backend": "phần sau",
|
||||||
"key": "Khóa Công khai",
|
"key": "Khóa Công khai",
|
||||||
"endpoint": "Điểm cuối Công khai",
|
"endpoint": "Điểm cuối Công khai",
|
||||||
"port": "Cổng Nghe",
|
"port": "Cổng Nghe",
|
||||||
|
@@ -98,7 +98,7 @@
|
|||||||
},
|
},
|
||||||
"interface": {
|
"interface": {
|
||||||
"headline": "接口状态",
|
"headline": "接口状态",
|
||||||
"mode": "模式",
|
"backend": "后端",
|
||||||
"key": "公钥",
|
"key": "公钥",
|
||||||
"endpoint": "公开节点",
|
"endpoint": "公开节点",
|
||||||
"port": "监听端口",
|
"port": "监听端口",
|
||||||
|
@@ -3,13 +3,17 @@ import { defineStore } from 'pinia'
|
|||||||
import { notify } from "@kyvg/vue3-notification";
|
import { notify } from "@kyvg/vue3-notification";
|
||||||
import { apiWrapper } from '@/helpers/fetch-wrapper'
|
import { apiWrapper } from '@/helpers/fetch-wrapper'
|
||||||
import router from '../router'
|
import router from '../router'
|
||||||
|
import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser';
|
||||||
|
import {base64_url_encode} from "@/helpers/encoding";
|
||||||
|
|
||||||
export const authStore = defineStore('auth',{
|
export const authStore = defineStore('auth',{
|
||||||
state: () => ({
|
state: () => ({
|
||||||
// initialize state from local storage to enable user to stay logged in
|
// initialize state from local storage to enable user to stay logged in
|
||||||
user: JSON.parse(localStorage.getItem('user')),
|
user: JSON.parse(localStorage.getItem('user')),
|
||||||
providers: [],
|
providers: [],
|
||||||
returnUrl: localStorage.getItem('returnUrl')
|
returnUrl: localStorage.getItem('returnUrl'),
|
||||||
|
webAuthnCredentials: [],
|
||||||
|
fetching: false,
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
UserIdentifier: (state) => state.user?.Identifier || 'unknown',
|
UserIdentifier: (state) => state.user?.Identifier || 'unknown',
|
||||||
@@ -18,6 +22,14 @@ export const authStore = defineStore('auth',{
|
|||||||
IsAuthenticated: (state) => state.user != null,
|
IsAuthenticated: (state) => state.user != null,
|
||||||
IsAdmin: (state) => state.user?.IsAdmin || false,
|
IsAdmin: (state) => state.user?.IsAdmin || false,
|
||||||
ReturnUrl: (state) => state.returnUrl || '/',
|
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: {
|
actions: {
|
||||||
SetReturnUrl(link) {
|
SetReturnUrl(link) {
|
||||||
@@ -60,6 +72,23 @@ export const authStore = defineStore('auth',{
|
|||||||
return Promise.reject(err)
|
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.
|
// Login returns promise that might have been rejected if the login attempt was not successful.
|
||||||
async Login(username, password) {
|
async Login(username, password) {
|
||||||
return apiWrapper.post(`/auth/login`, { username, password })
|
return apiWrapper.post(`/auth/login`, { username, password })
|
||||||
@@ -93,6 +122,157 @@ export const authStore = defineStore('auth',{
|
|||||||
|
|
||||||
await router.push('/login')
|
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
|
// -- internal setters
|
||||||
setUserInfo(userInfo) {
|
setUserInfo(userInfo) {
|
||||||
// store user details and jwt in local storage to keep user logged in between page refreshes
|
// 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')
|
localStorage.removeItem('user')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setWebAuthnCredentials(credentials) {
|
||||||
|
this.fetching = false
|
||||||
|
this.webAuthnCredentials = credentials
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@@ -142,8 +142,8 @@ export const peerStore = defineStore('peers', {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async MailPeerConfig(linkOnly, ids) {
|
async MailPeerConfig(linkOnly, style, ids) {
|
||||||
return apiWrapper.post(`${baseUrl}/config-mail`, {
|
return apiWrapper.post(`${baseUrl}/config-mail?style=${style}`, {
|
||||||
Identifiers: ids,
|
Identifiers: ids,
|
||||||
LinkOnly: linkOnly
|
LinkOnly: linkOnly
|
||||||
})
|
})
|
||||||
@@ -158,8 +158,8 @@ export const peerStore = defineStore('peers', {
|
|||||||
throw new Error(error)
|
throw new Error(error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async LoadPeerConfig(id) {
|
async LoadPeerConfig(id, style) {
|
||||||
return apiWrapper.get(`${baseUrl}/config/${base64_url_encode(id)}`)
|
return apiWrapper.get(`${baseUrl}/config/${base64_url_encode(id)}?style=${style}`)
|
||||||
.then(this.setPeerConfig)
|
.then(this.setPeerConfig)
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
this.configuration = ""
|
this.configuration = ""
|
||||||
|
@@ -26,7 +26,7 @@ onMounted(async () => {
|
|||||||
<div class="form-group d-inline">
|
<div class="form-group d-inline">
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input v-model="audit.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="audit.afterPageSizeChange">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -13,21 +13,21 @@ const auth = authStore()
|
|||||||
<p class="lead">{{ $t('home.abstract') }}</p>
|
<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>
|
<h2 class="display-5">{{ $t('home.profiles.headline') }}</h2>
|
||||||
<p class="lead">{{ $t('home.profiles.abstract') }}</p>
|
<p class="lead">{{ $t('home.profiles.abstract') }}</p>
|
||||||
<hr class="my-4">
|
<hr class="my-4">
|
||||||
<p>{{ $t('home.profiles.content') }}</p>
|
<p class="card-text">{{ $t('home.profiles.content') }}</p>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
<RouterLink :to="{ name: 'profile' }" class="btn btn-primary btn-lg">{{ $t('home.profiles.button') }}</RouterLink>
|
<RouterLink :to="{ name: 'profile' }" class="btn btn-primary btn-lg">{{ $t('home.profiles.button') }}</RouterLink>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<h2 class="display-5">{{ $t('home.admin.headline') }}</h2>
|
||||||
<p class="lead">{{ $t('home.admin.abstract') }}</p>
|
<p class="lead">{{ $t('home.admin.abstract') }}</p>
|
||||||
<hr class="my-4">
|
<hr class="my-4">
|
||||||
<p>{{ $t('home.admin.content') }}</p>
|
<p class="card-text">{{ $t('home.admin.content') }}</p>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
<RouterLink :to="{ name: 'interfaces' }" class="btn btn-primary btn-lg me-2">{{ $t('home.admin.button-admin') }}
|
<RouterLink :to="{ name: 'interfaces' }" class="btn btn-primary btn-lg me-2">{{ $t('home.admin.button-admin') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -65,7 +65,7 @@ const auth = authStore()
|
|||||||
<div class="card-body d-flex flex-column">
|
<div class="card-body d-flex flex-column">
|
||||||
<h4 class="card-title">{{ $t('home.about-portal.headline') }}</h4>
|
<h4 class="card-title">{{ $t('home.about-portal.headline') }}</h4>
|
||||||
<p class="card-text">{{ $t('home.about-portal.content') }}</p>
|
<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>
|
rel="noopener noreferrer" class="mt-auto btn btn-primary btn-sm">{{ $t('home.about-portal.button') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -5,17 +5,20 @@ import PeerMultiCreateModal from "../components/PeerMultiCreateModal.vue";
|
|||||||
import InterfaceEditModal from "../components/InterfaceEditModal.vue";
|
import InterfaceEditModal from "../components/InterfaceEditModal.vue";
|
||||||
import InterfaceViewModal from "../components/InterfaceViewModal.vue";
|
import InterfaceViewModal from "../components/InterfaceViewModal.vue";
|
||||||
|
|
||||||
import {onMounted, ref} from "vue";
|
import {computed, onMounted, ref} from "vue";
|
||||||
import {peerStore} from "@/stores/peers";
|
import {peerStore} from "@/stores/peers";
|
||||||
import {interfaceStore} from "@/stores/interfaces";
|
import {interfaceStore} from "@/stores/interfaces";
|
||||||
import {notify} from "@kyvg/vue3-notification";
|
import {notify} from "@kyvg/vue3-notification";
|
||||||
import {settingsStore} from "@/stores/settings";
|
import {settingsStore} from "@/stores/settings";
|
||||||
import {humanFileSize} from '@/helpers/utils';
|
import {humanFileSize} from '@/helpers/utils';
|
||||||
|
import {useI18n} from "vue-i18n";
|
||||||
|
|
||||||
const settings = settingsStore()
|
const settings = settingsStore()
|
||||||
const interfaces = interfaceStore()
|
const interfaces = interfaceStore()
|
||||||
const peers = peerStore()
|
const peers = peerStore()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const viewedPeerId = ref("")
|
const viewedPeerId = ref("")
|
||||||
const editPeerId = ref("")
|
const editPeerId = ref("")
|
||||||
const multiCreatePeerId = ref("")
|
const multiCreatePeerId = ref("")
|
||||||
@@ -45,6 +48,33 @@ function calculateInterfaceName(id, name) {
|
|||||||
return result
|
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() {
|
async function download() {
|
||||||
await interfaces.LoadInterfaceConfig(interfaces.GetSelected.Identifier)
|
await interfaces.LoadInterfaceConfig(interfaces.GetSelected.Identifier)
|
||||||
|
|
||||||
@@ -112,7 +142,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="input-group mb-3">
|
<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>
|
<i class="fa-solid fa-plus-circle"></i>
|
||||||
</button>
|
</button>
|
||||||
<select v-model="interfaces.selected" :disabled="interfaces.Count===0" class="form-select" @change="() => { peers.LoadPeers(); peers.LoadStats() }">
|
<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="card-header">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 col-lg-8">
|
<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>
|
<span v-if="interfaces.GetSelected.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="interfaces.GetSelected.DisabledReason"></i></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-lg-4 text-lg-end">
|
<div class="col-12 col-lg-4 text-lg-end">
|
||||||
@@ -314,7 +344,7 @@ onMounted(async () => {
|
|||||||
<div class="form-group d-inline">
|
<div class="form-group d-inline">
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input v-model="peers.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="peers.afterPageSizeChange">
|
<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>
|
</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>
|
<span v-if="!peer.Disabled && peer.ExpiresAt" class="text-warning" :title="$t('interfaces.peer-expiring') + ' ' + peer.ExpiresAt"><i class="fas fa-hourglass-end expiring-peer"></i></span>
|
||||||
</td>
|
</td>
|
||||||
<td><span v-if="peer.DisplayName" :title="peer.Identifier">{{peer.DisplayName}}</span><span v-else :title="peer.Identifier">{{ $filters.truncate(peer.Identifier, 10)}}</span></td>
|
<td><span v-if="peer.DisplayName" :title="peer.Identifier">{{peer.DisplayName}}</span><span v-else :title="peer.Identifier">{{ $filters.truncate(peer.Identifier, 10)}}</span></td>
|
||||||
<td>{{peer.UserIdentifier}}</td>
|
<td><span :title="peer.UserDisplayName">{{peer.UserIdentifier}}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<span v-for="ip in peer.Addresses" :key="ip" class="badge bg-light me-1">{{ ip }}</span>
|
<span v-for="ip in peer.Addresses" :key="ip" class="badge bg-light me-1">{{ ip }}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -429,3 +459,5 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
|
||||||
import {computed, ref} from "vue";
|
import {computed, onMounted, ref} from "vue";
|
||||||
import {authStore} from "@/stores/auth";
|
import {authStore} from "@/stores/auth";
|
||||||
import router from '../router/index.js'
|
import router from '../router/index.js'
|
||||||
import {notify} from "@kyvg/vue3-notification";
|
import {notify} from "@kyvg/vue3-notification";
|
||||||
@@ -16,6 +16,14 @@ const password = ref("")
|
|||||||
const usernameInvalid = computed(() => username.value === "")
|
const usernameInvalid = computed(() => username.value === "")
|
||||||
const passwordInvalid = computed(() => password.value === "")
|
const passwordInvalid = computed(() => password.value === "")
|
||||||
const disableLoginBtn = computed(() => username.value === "" || password.value === "" || loggingIn.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 () {
|
const login = async function () {
|
||||||
console.log("Performing login for user:", username.value);
|
console.log("Performing login for user:", username.value);
|
||||||
@@ -28,7 +36,34 @@ const login = async function () {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
loggingIn.value = false;
|
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);
|
router.push(auth.ReturnUrl);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -66,7 +101,7 @@ const externalLogin = function (provider) {
|
|||||||
</div></div>
|
</div></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<fieldset>
|
<fieldset v-if="showLoginForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="inputUsername">{{ $t('login.username.label') }}</label>
|
<label class="form-label" for="inputUsername">{{ $t('login.username.label') }}</label>
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
@@ -85,17 +120,46 @@ const externalLogin = function (provider) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mt-5 d-flex">
|
<div class="row mt-5 mb-2">
|
||||||
<div :class="{'col-lg-4':auth.LoginProviders.length < 3, 'col-lg-12':auth.LoginProviders.length >= 3}" class="d-flex mb-2">
|
<div class="col-sm-4 col-xs-12">
|
||||||
<button :disabled="disableLoginBtn" class="btn btn-primary flex-fill" type="submit" @click.prevent="login">
|
<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>
|
{{ $t('login.button') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- OpenIdConnect / OAUTH providers -->
|
||||||
<button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}"
|
<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"
|
:disabled="loggingIn" :title="provider.Name" class="btn btn-outline-primary flex-fill"
|
||||||
v-html="provider.Name" @click.prevent="externalLogin(provider)"></button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,7 +168,6 @@ const externalLogin = function (provider) {
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -65,7 +65,7 @@ onMounted(async () => {
|
|||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input v-model="profile.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text"
|
<input v-model="profile.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text"
|
||||||
@keyup="profile.afterPageSizeChange">
|
@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>
|
class="fa-solid fa-search"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@ onMounted(async () => {
|
|||||||
<div class="col-12 col-lg-3 text-lg-end">
|
<div class="col-12 col-lg-3 text-lg-end">
|
||||||
<div class="form-group" v-if="settings.Setting('SelfProvisioning')">
|
<div class="form-group" v-if="settings.Setting('SelfProvisioning')">
|
||||||
<div class="input-group mb-3">
|
<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>
|
<i class="fa fa-plus me-1"></i><i class="fa fa-user"></i>
|
||||||
</button>
|
</button>
|
||||||
<select v-model="profile.selectedInterfaceId" :disabled="profile.CountInterfaces===0" class="form-select">
|
<select v-model="profile.selectedInterfaceId" :disabled="profile.CountInterfaces===0" class="form-select">
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from "vue";
|
import {onMounted, ref} from "vue";
|
||||||
import { profileStore } from "@/stores/profile";
|
import { profileStore } from "@/stores/profile";
|
||||||
import { settingsStore } from "@/stores/settings";
|
import { settingsStore } from "@/stores/settings";
|
||||||
import { authStore } from "../stores/auth";
|
import { authStore } from "../stores/auth";
|
||||||
@@ -10,8 +10,30 @@ const auth = authStore()
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await profile.LoadUser()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -22,7 +44,7 @@ onMounted(async () => {
|
|||||||
<p class="lead">{{ $t('settings.abstract') }}</p>
|
<p class="lead">{{ $t('settings.abstract') }}</p>
|
||||||
|
|
||||||
<div v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')">
|
<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>
|
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
|
||||||
<p class="lead">{{ $t('settings.api.abstract') }}</p>
|
<p class="lead">{{ $t('settings.api.abstract') }}</p>
|
||||||
<hr class="my-4">
|
<hr class="my-4">
|
||||||
@@ -50,7 +72,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="row mt-5">
|
<div class="row mt-5">
|
||||||
<div class="col-6">
|
<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') }}
|
<i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,14 +81,96 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
|
||||||
<p class="lead">{{ $t('settings.api.abstract') }}</p>
|
<p class="lead">{{ $t('settings.api.abstract') }}</p>
|
||||||
<hr class="my-4">
|
<hr class="my-4">
|
||||||
<p>{{ $t('settings.api.inactive-description') }}</p>
|
<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') }}
|
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
|
@@ -35,7 +35,7 @@ onMounted(() => {
|
|||||||
<div class="form-group d-inline">
|
<div class="form-group d-inline">
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input v-model="users.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="users.afterPageSizeChange">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
95
go.mod
95
go.mod
@@ -4,31 +4,32 @@ go 1.24.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/a8m/envsubst v1.4.3
|
github.com/a8m/envsubst v1.4.3
|
||||||
github.com/alexedwards/scs/v2 v2.8.0
|
github.com/alexedwards/scs/v2 v2.9.0
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1
|
github.com/coreos/go-oidc/v3 v3.16.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/go-ldap/ldap/v3 v3.4.11
|
github.com/go-ldap/ldap/v3 v3.4.12
|
||||||
github.com/go-pkgz/routegroup v1.4.1
|
github.com/go-pkgz/routegroup v1.5.3
|
||||||
github.com/go-playground/validator/v10 v10.26.0
|
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/google/uuid v1.6.0
|
||||||
github.com/prometheus-community/pro-bing v0.7.0
|
github.com/prometheus-community/pro-bing v0.7.0
|
||||||
github.com/prometheus/client_golang v1.22.0
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/swaggo/swag v1.16.4
|
github.com/swaggo/swag v1.16.6
|
||||||
github.com/vardius/message-bus v1.1.5
|
github.com/vardius/message-bus v1.1.5
|
||||||
github.com/vishvananda/netlink v1.3.1
|
github.com/vishvananda/netlink v1.3.1
|
||||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||||
github.com/yeqown/go-qrcode/v2 v2.2.5
|
github.com/yeqown/go-qrcode/v2 v2.2.5
|
||||||
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
|
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.42.0
|
||||||
golang.org/x/oauth2 v0.30.0
|
golang.org/x/oauth2 v0.31.0
|
||||||
golang.org/x/sys v0.33.0
|
golang.org/x/sys v0.36.0
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/mysql v1.5.7
|
gorm.io/driver/mysql v1.6.0
|
||||||
gorm.io/driver/postgres v1.5.11
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlserver v1.5.4
|
gorm.io/driver/sqlserver v1.6.1
|
||||||
gorm.io/gorm v1.26.1
|
gorm.io/gorm v1.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -39,56 +40,68 @@ require (
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/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/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.1 // indirect
|
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||||
github.com/go-openapi/spec v0.21.0 // indirect
|
github.com/go-openapi/spec v0.22.0 // indirect
|
||||||
github.com/go-openapi/swag v0.23.1 // 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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||||
github.com/go-test/deep v1.1.1 // indirect
|
github.com/go-test/deep v1.1.1 // indirect
|
||||||
|
github.com/go-webauthn/x v0.1.25 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/google/go-tpm v0.9.6 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.7.4 // indirect
|
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
|
||||||
github.com/josharian/native v1.1.0 // indirect
|
|
||||||
github.com/leodido/go-urn v1.4.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/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mdlayher/genetlink v1.3.2 // 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/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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.63.0 // indirect
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.16.0 // indirect
|
github.com/prometheus/procfs v0.17.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
|
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
|
||||||
github.com/vishvananda/netns v0.0.5 // indirect
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/net v0.39.0 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/sync v0.14.0 // indirect
|
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
|
||||||
golang.org/x/text v0.25.0 // indirect
|
golang.org/x/mod v0.28.0 // indirect
|
||||||
golang.org/x/tools v0.32.0 // indirect
|
golang.org/x/net v0.44.0 // indirect
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
golang.org/x/text v0.29.0 // indirect
|
||||||
modernc.org/libc v1.63.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/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.10.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.37.0 // indirect
|
modernc.org/sqlite v1.39.0 // indirect
|
||||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
311
go.sum
311
go.sum
@@ -2,41 +2,45 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.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.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.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.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.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.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo=
|
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/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/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.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 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.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.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 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY=
|
github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY=
|
||||||
github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU=
|
github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU=
|
||||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
|
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
|
||||||
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -44,56 +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/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
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 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
|
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
|
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
|
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||||
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
|
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||||
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||||
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
|
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||||
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
|
||||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
|
||||||
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
|
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||||
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
|
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
||||||
github.com/go-pkgz/routegroup v1.4.1 h1:iw1yW3lXuurZZOv/DF9fY8Mkpvy6J9UjBiP1oDIQE/s=
|
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
||||||
github.com/go-pkgz/routegroup v1.4.1/go.mod h1:kDDPDRLRiRY1vnENrZJw1jQAzQX7fvsbsHGRQFNQfKc=
|
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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
github.com/go-playground/validator/v10 v10.27.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.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
|
||||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
|
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
|
||||||
|
github.com/go-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.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.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 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
|
||||||
|
github.com/google/go-tpm v0.9.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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||||
@@ -105,8 +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/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||||
@@ -125,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/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
|
||||||
github.com/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 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
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.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM=
|
||||||
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
|
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 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
||||||
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||||
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
|
||||||
github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw=
|
github.com/microsoft/go-mssqldb v1.9.3 h1:hy4p+LDC8LIGvI3JATnLVmBOLMJbmn5X400mr5j0lPs=
|
||||||
github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo=
|
github.com/microsoft/go-mssqldb v1.9.3/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
|
||||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
|
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
|
||||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
||||||
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
@@ -163,25 +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-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
|
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-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
|
||||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
|
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||||
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
|
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||||
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
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.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
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=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -189,10 +217,11 @@ 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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
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.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
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-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
|
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
|
||||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||||
@@ -202,6 +231,8 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW
|
|||||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
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 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
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 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
|
||||||
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
|
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=
|
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||||
@@ -211,23 +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 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
||||||
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
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=
|
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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
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.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
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.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||||
|
golang.org/x/crypto v0.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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
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.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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
@@ -238,18 +284,26 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
|||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
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.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
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.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||||
|
golang.org/x/net v0.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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -263,11 +317,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.10.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.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.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.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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.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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
@@ -275,9 +334,13 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
|||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
|
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.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
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.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||||
|
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
|
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
@@ -288,21 +351,26 @@ golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
golang.org/x/text v0.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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
@@ -313,39 +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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g=
|
gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY=
|
||||||
gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g=
|
gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
|
||||||
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||||
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
|
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||||
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
|
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
|
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||||
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/libc v1.63.0 h1:wKzb61wOGCzgahQBORb1b0dZonh8Ufzl/7r4Yf1D5YA=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/libc v1.63.0/go.mod h1:wDzH1mgz1wUIEwottFt++POjGRO9sgyQKrpXaz3x89E=
|
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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
||||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||||
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
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 {
|
func (r *SqlRepo) migrate() error {
|
||||||
slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{}))
|
slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{}))
|
||||||
slog.Debug("running migration: user", "result", r.db.AutoMigrate(&domain.User{}))
|
slog.Debug("running migration: user", "result", r.db.AutoMigrate(&domain.User{}))
|
||||||
|
slog.Debug("running migration: user webauthn credentials", "result",
|
||||||
|
r.db.AutoMigrate(&domain.UserWebauthnCredential{}))
|
||||||
slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{}))
|
slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{}))
|
||||||
slog.Debug("running migration: peer", "result", r.db.AutoMigrate(&domain.Peer{}))
|
slog.Debug("running migration: peer", "result", r.db.AutoMigrate(&domain.Peer{}))
|
||||||
slog.Debug("running migration: peer status", "result", r.db.AutoMigrate(&domain.PeerStatus{}))
|
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) {
|
func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
|
||||||
var user domain.User
|
var user domain.User
|
||||||
|
|
||||||
err := r.db.WithContext(ctx).First(&user, id).Error
|
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").First(&user, id).Error
|
||||||
|
|
||||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, domain.ErrNotFound
|
return nil, domain.ErrNotFound
|
||||||
@@ -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) {
|
func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||||
var users []domain.User
|
var users []domain.User
|
||||||
|
|
||||||
err := r.db.WithContext(ctx).Where("email = ?", email).Find(&users).Error
|
err := r.db.WithContext(ctx).Where("email = ?", email).Preload("WebAuthnCredentialList").Find(&users).Error
|
||||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, domain.ErrNotFound
|
return nil, domain.ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -785,11 +787,26 @@ func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.Use
|
|||||||
return &user, nil
|
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.
|
// GetAllUsers returns all users.
|
||||||
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
||||||
var users []domain.User
|
var users []domain.User
|
||||||
|
|
||||||
err := r.db.WithContext(ctx).Find(&users).Error
|
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Find(&users).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -808,6 +825,7 @@ func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User,
|
|||||||
Or("firstname LIKE ?", searchValue).
|
Or("firstname LIKE ?", searchValue).
|
||||||
Or("lastname LIKE ?", searchValue).
|
Or("lastname LIKE ?", searchValue).
|
||||||
Or("email LIKE ?", searchValue).
|
Or("email LIKE ?", searchValue).
|
||||||
|
Preload("WebAuthnCredentialList").
|
||||||
Find(&users).Error
|
Find(&users).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -853,7 +871,7 @@ func (r *SqlRepo) SaveUser(
|
|||||||
|
|
||||||
// DeleteUser deletes the user with the given id.
|
// DeleteUser deletes the user with the given id.
|
||||||
func (r *SqlRepo) DeleteUser(ctx context.Context, id domain.UserIdentifier) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -897,6 +915,11 @@ func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *doma
|
|||||||
return err
|
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
|
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.peerReceivedBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesReceived))
|
||||||
m.peerSendBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesTransmitted))
|
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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/vishvananda/netlink"
|
"github.com/vishvananda/netlink"
|
||||||
@@ -16,8 +17,9 @@ import (
|
|||||||
|
|
||||||
// WgRepo implements all low-level WireGuard interactions.
|
// WgRepo implements all low-level WireGuard interactions.
|
||||||
type WgRepo struct {
|
type WgRepo struct {
|
||||||
wg lowlevel.WireGuardClient
|
wg lowlevel.WireGuardClient
|
||||||
nl lowlevel.NetlinkClient
|
nl lowlevel.NetlinkClient
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWireGuardRepository creates a new WgRepo instance.
|
// NewWireGuardRepository creates a new WgRepo instance.
|
||||||
@@ -31,8 +33,9 @@ func NewWireGuardRepository() *WgRepo {
|
|||||||
nl := &lowlevel.NetlinkManager{}
|
nl := &lowlevel.NetlinkManager{}
|
||||||
|
|
||||||
repo := &WgRepo{
|
repo := &WgRepo{
|
||||||
wg: wg,
|
wg: wg,
|
||||||
nl: nl,
|
nl: nl,
|
||||||
|
log: slog.Default().With(slog.String("adapter", "wireguard")),
|
||||||
}
|
}
|
||||||
|
|
||||||
return repo
|
return repo
|
||||||
@@ -40,8 +43,10 @@ func NewWireGuardRepository() *WgRepo {
|
|||||||
|
|
||||||
// GetInterfaces returns all existing WireGuard interfaces.
|
// GetInterfaces returns all existing WireGuard interfaces.
|
||||||
func (r *WgRepo) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) {
|
func (r *WgRepo) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) {
|
||||||
|
r.log.Debug("getting all interfaces")
|
||||||
devices, err := r.wg.Devices()
|
devices, err := r.wg.Devices()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
r.log.Error("failed to get devices", "error", err)
|
||||||
return nil, fmt.Errorf("device list error: %w", 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.
|
// GetInterface returns the interface with the given id.
|
||||||
// If no interface is found, an error os.ErrNotExist is returned.
|
// If no interface is found, an error os.ErrNotExist is returned.
|
||||||
func (r *WgRepo) GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) {
|
func (r *WgRepo) GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) {
|
||||||
|
r.log.Debug("getting interface", "id", id)
|
||||||
return r.getInterface(id)
|
return r.getInterface(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPeers returns all peers associated with the given interface id.
|
// GetPeers returns all peers associated with the given interface id.
|
||||||
// If the requested interface is found, an error os.ErrNotExist is returned.
|
// If the requested interface is found, an error os.ErrNotExist is returned.
|
||||||
func (r *WgRepo) GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) {
|
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))
|
device, err := r.wg.Device(string(deviceId))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
r.log.Error("failed to get device", "deviceId", deviceId, "error", err)
|
||||||
return nil, fmt.Errorf("device error: %w", err)
|
return nil, fmt.Errorf("device error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +98,7 @@ func (r *WgRepo) GetPeer(
|
|||||||
deviceId domain.InterfaceIdentifier,
|
deviceId domain.InterfaceIdentifier,
|
||||||
id domain.PeerIdentifier,
|
id domain.PeerIdentifier,
|
||||||
) (*domain.PhysicalPeer, error) {
|
) (*domain.PhysicalPeer, error) {
|
||||||
|
r.log.Debug("getting peer", "deviceId", deviceId, "peerId", id)
|
||||||
return r.getPeer(deviceId, id)
|
return r.getPeer(deviceId, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,25 +183,31 @@ func (r *WgRepo) SaveInterface(
|
|||||||
id domain.InterfaceIdentifier,
|
id domain.InterfaceIdentifier,
|
||||||
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
|
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
|
||||||
) error {
|
) error {
|
||||||
|
r.log.Debug("saving interface", "id", id)
|
||||||
physicalInterface, err := r.getOrCreateInterface(id)
|
physicalInterface, err := r.getOrCreateInterface(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
r.log.Error("failed to get or create interface", "id", id, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if updateFunc != nil {
|
if updateFunc != nil {
|
||||||
physicalInterface, err = updateFunc(physicalInterface)
|
physicalInterface, err = updateFunc(physicalInterface)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
r.log.Error("interface update function failed", "id", id, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.updateLowLevelInterface(physicalInterface); err != nil {
|
if err := r.updateLowLevelInterface(physicalInterface); err != nil {
|
||||||
|
r.log.Error("failed to update low level interface", "id", id, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := r.updateWireGuardInterface(physicalInterface); err != nil {
|
if err := r.updateWireGuardInterface(physicalInterface); err != nil {
|
||||||
|
r.log.Error("failed to update wireguard interface", "id", id, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.log.Debug("successfully saved interface", "id", id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,10 +338,13 @@ func (r *WgRepo) updateWireGuardInterface(pi *domain.PhysicalInterface) error {
|
|||||||
// DeleteInterface deletes the interface with the given id.
|
// DeleteInterface deletes the interface with the given id.
|
||||||
// If the requested interface is found, no error is returned.
|
// If the requested interface is found, no error is returned.
|
||||||
func (r *WgRepo) DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error {
|
func (r *WgRepo) DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error {
|
||||||
|
r.log.Debug("deleting interface", "id", id)
|
||||||
if err := r.deleteLowLevelInterface(id); err != nil {
|
if err := r.deleteLowLevelInterface(id); err != nil {
|
||||||
|
r.log.Error("failed to delete low level interface", "id", id, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.log.Debug("successfully deleted interface", "id", id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,20 +374,25 @@ func (r *WgRepo) SavePeer(
|
|||||||
id domain.PeerIdentifier,
|
id domain.PeerIdentifier,
|
||||||
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
|
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
|
||||||
) error {
|
) error {
|
||||||
|
r.log.Debug("saving peer", "deviceId", deviceId, "peerId", id)
|
||||||
physicalPeer, err := r.getOrCreatePeer(deviceId, id)
|
physicalPeer, err := r.getOrCreatePeer(deviceId, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
r.log.Error("failed to get or create peer", "deviceId", deviceId, "peerId", id, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
physicalPeer, err = updateFunc(physicalPeer)
|
physicalPeer, err = updateFunc(physicalPeer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
r.log.Error("peer update function failed", "deviceId", deviceId, "peerId", id, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.updatePeer(deviceId, physicalPeer); err != nil {
|
if err := r.updatePeer(deviceId, physicalPeer); err != nil {
|
||||||
|
r.log.Error("failed to update peer", "deviceId", deviceId, "peerId", id, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.log.Debug("successfully saved peer", "deviceId", deviceId, "peerId", id)
|
||||||
return nil
|
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}})
|
err := r.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
r.log.Error("failed to configure device for peer update", "deviceId", deviceId, "peerId", pp.Identifier, "error", err)
|
||||||
return 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.
|
// DeletePeer deletes the peer with the given id.
|
||||||
// If the requested interface or peer is found, no error is returned.
|
// 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 {
|
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() {
|
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)
|
err := r.deletePeer(deviceId, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
r.log.Error("failed to delete peer", "deviceId", deviceId, "peerId", id, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.log.Debug("successfully deleted peer", "deviceId", deviceId, "peerId", id)
|
||||||
return nil
|
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}})
|
err := r.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
r.log.Error("failed to configure device for peer deletion", "deviceId", deviceId, "peerId", id, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -175,30 +175,78 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/auth/{provider}/callback": {
|
"/auth/webauthn/credential/{id}": {
|
||||||
"get": {
|
"put": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"Authentication"
|
"Authentication"
|
||||||
],
|
],
|
||||||
"summary": "Handle the OAuth callback.",
|
"summary": "Update a WebAuthn credential.",
|
||||||
"operationId": "auth_handleOauthCallbackGet",
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"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": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -206,15 +254,67 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Authentication"
|
"Authentication"
|
||||||
],
|
],
|
||||||
"summary": "Initiate the OAuth login flow.",
|
"summary": "Get all available external login providers.",
|
||||||
"operationId": "auth_handleOauthInitiateGet",
|
"operationId": "auth_handleWebAuthnCredentialsGet",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -719,6 +819,12 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/model.PeerMailRequest"
|
"$ref": "#/definitions/model.PeerMailRequest"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "The configuration style",
|
||||||
|
"name": "style",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -758,6 +864,12 @@
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "The configuration style",
|
||||||
|
"name": "style",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -799,6 +911,12 @@
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "The configuration style",
|
||||||
|
"name": "style",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -1663,6 +1781,11 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Backend": {
|
||||||
|
"description": "the backend used for this interface e.g., local, mikrotik, ...",
|
||||||
|
"type": "string",
|
||||||
|
"example": "local"
|
||||||
|
},
|
||||||
"Disabled": {
|
"Disabled": {
|
||||||
"description": "flag that specifies if the interface is enabled (up) or not (down)",
|
"description": "flag that specifies if the interface is enabled (up) or not (down)",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -2131,14 +2254,40 @@
|
|||||||
"ApiAdminOnly": {
|
"ApiAdminOnly": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"AvailableBackends": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/model.SettingsBackendNames"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LoginFormVisible": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"MailLinkOnly": {
|
"MailLinkOnly": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"MinPasswordLength": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"PersistentConfigSupported": {
|
"PersistentConfigSupported": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"SelfProvisioning": {
|
"SelfProvisioning": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"WebAuthnEnabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model.SettingsBackendNames": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"Id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"Name": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2207,6 +2356,28 @@
|
|||||||
"type": "string"
|
"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:
|
items:
|
||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
|
Backend:
|
||||||
|
description: the backend used for this interface e.g., local, mikrotik, ...
|
||||||
|
example: local
|
||||||
|
type: string
|
||||||
Disabled:
|
Disabled:
|
||||||
description: flag that specifies if the interface is enabled (up) or not (down)
|
description: flag that specifies if the interface is enabled (up) or not (down)
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -381,6 +385,12 @@ definitions:
|
|||||||
properties:
|
properties:
|
||||||
ApiAdminOnly:
|
ApiAdminOnly:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
AvailableBackends:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/model.SettingsBackendNames'
|
||||||
|
type: array
|
||||||
|
LoginFormVisible:
|
||||||
|
type: boolean
|
||||||
MailLinkOnly:
|
MailLinkOnly:
|
||||||
type: boolean
|
type: boolean
|
||||||
MinPasswordLength:
|
MinPasswordLength:
|
||||||
@@ -389,6 +399,15 @@ definitions:
|
|||||||
type: boolean
|
type: boolean
|
||||||
SelfProvisioning:
|
SelfProvisioning:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
WebAuthnEnabled:
|
||||||
|
type: boolean
|
||||||
|
type: object
|
||||||
|
model.SettingsBackendNames:
|
||||||
|
properties:
|
||||||
|
Id:
|
||||||
|
type: string
|
||||||
|
Name:
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
model.User:
|
model.User:
|
||||||
properties:
|
properties:
|
||||||
@@ -435,6 +454,20 @@ definitions:
|
|||||||
Source:
|
Source:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
info:
|
||||||
contact:
|
contact:
|
||||||
name: WireGuard Portal Developers
|
name: WireGuard Portal Developers
|
||||||
@@ -550,6 +583,102 @@ paths:
|
|||||||
summary: Get information about the currently logged-in user.
|
summary: Get information about the currently logged-in user.
|
||||||
tags:
|
tags:
|
||||||
- Authentication
|
- 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:
|
/config/frontend.js:
|
||||||
get:
|
get:
|
||||||
operationId: config_handleConfigJsGet
|
operationId: config_handleConfigJsGet
|
||||||
@@ -958,6 +1087,10 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/model.PeerMailRequest'
|
$ref: '#/definitions/model.PeerMailRequest'
|
||||||
|
- description: The configuration style
|
||||||
|
in: query
|
||||||
|
name: style
|
||||||
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -983,6 +1116,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
- description: The configuration style
|
||||||
|
in: query
|
||||||
|
name: style
|
||||||
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- image/png
|
- image/png
|
||||||
- application/json
|
- application/json
|
||||||
@@ -1011,6 +1148,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
- description: The configuration style
|
||||||
|
in: query
|
||||||
|
name: style
|
||||||
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
|
@@ -2086,6 +2086,11 @@
|
|||||||
"InterfaceIdentifier"
|
"InterfaceIdentifier"
|
||||||
],
|
],
|
||||||
"properties": {
|
"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": {
|
"InterfaceIdentifier": {
|
||||||
"description": "InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to.",
|
"description": "InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
@@ -445,6 +445,12 @@ definitions:
|
|||||||
type: object
|
type: object
|
||||||
models.ProvisioningRequest:
|
models.ProvisioningRequest:
|
||||||
properties:
|
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:
|
InterfaceIdentifier:
|
||||||
description: InterfaceIdentifier is the identifier of the WireGuard interface
|
description: InterfaceIdentifier is the identifier of the WireGuard interface
|
||||||
the peer should be linked to.
|
the peer should be linked to.
|
||||||
|
@@ -100,6 +100,7 @@ func (s *Server) Run(ctx context.Context, listenAddress string) {
|
|||||||
srvContext, cancelFn := context.WithCancel(ctx)
|
srvContext, cancelFn := context.WithCancel(ctx)
|
||||||
go func() {
|
go func() {
|
||||||
var err error
|
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 != "" {
|
if s.cfg.Web.CertFile != "" && s.cfg.Web.KeyFile != "" {
|
||||||
err = srv.ListenAndServeTLS(s.cfg.Web.CertFile, s.cfg.Web.KeyFile)
|
err = srv.ListenAndServeTLS(s.cfg.Web.CertFile, s.cfg.Web.KeyFile)
|
||||||
} else {
|
} else {
|
||||||
|
@@ -27,12 +27,12 @@ type PeerServicePeerManager interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PeerServiceConfigFileManager interface {
|
type PeerServiceConfigFileManager interface {
|
||||||
GetPeerConfig(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) (io.Reader, error)
|
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeerServiceMailManager interface {
|
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
|
// endregion dependencies
|
||||||
@@ -95,16 +95,24 @@ func (p PeerService) DeletePeer(ctx context.Context, id domain.PeerIdentifier) e
|
|||||||
return p.peers.DeletePeer(ctx, id)
|
return p.peers.DeletePeer(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p PeerService) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) {
|
func (p PeerService) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) {
|
||||||
return p.configFile.GetPeerConfig(ctx, id)
|
return p.configFile.GetPeerConfig(ctx, id, style)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p PeerService) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) {
|
func (p PeerService) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (
|
||||||
return p.configFile.GetPeerConfigQrCode(ctx, id)
|
io.Reader,
|
||||||
|
error,
|
||||||
|
) {
|
||||||
|
return p.configFile.GetPeerConfigQrCode(ctx, id, style)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p PeerService) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error {
|
func (p PeerService) SendPeerEmail(
|
||||||
return p.mailer.SendPeerEmail(ctx, linkOnly, peers...)
|
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) {
|
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
|
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 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
|
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 {
|
type Session interface {
|
||||||
|
@@ -29,12 +29,54 @@ type AuthenticationService interface {
|
|||||||
OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error)
|
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 {
|
type AuthEndpoint struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
authService AuthenticationService
|
authService AuthenticationService
|
||||||
authenticator Authenticator
|
authenticator Authenticator
|
||||||
session Session
|
session Session
|
||||||
validate Validator
|
validate Validator
|
||||||
|
webAuthn WebAuthnService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthEndpoint(
|
func NewAuthEndpoint(
|
||||||
@@ -43,6 +85,7 @@ func NewAuthEndpoint(
|
|||||||
session Session,
|
session Session,
|
||||||
validator Validator,
|
validator Validator,
|
||||||
authService AuthenticationService,
|
authService AuthenticationService,
|
||||||
|
webAuthn WebAuthnService,
|
||||||
) AuthEndpoint {
|
) AuthEndpoint {
|
||||||
return AuthEndpoint{
|
return AuthEndpoint{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@@ -50,6 +93,7 @@ func NewAuthEndpoint(
|
|||||||
authenticator: authenticator,
|
authenticator: authenticator,
|
||||||
session: session,
|
session: session,
|
||||||
validate: validator,
|
validate: validator,
|
||||||
|
webAuthn: webAuthn,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +110,19 @@ func (e AuthEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
|||||||
apiGroup.HandleFunc("GET /login/{provider}/init", e.handleOauthInitiateGet())
|
apiGroup.HandleFunc("GET /login/{provider}/init", e.handleOauthInitiateGet())
|
||||||
apiGroup.HandleFunc("GET /login/{provider}/callback", e.handleOauthCallbackGet())
|
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.HandleFunc("POST /login", e.handleLoginPost())
|
||||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /logout", e.handleLogoutPost())
|
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /logout", e.handleLogoutPost())
|
||||||
}
|
}
|
||||||
@@ -398,3 +455,237 @@ func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
|
|||||||
|
|
||||||
return true
|
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/core/respond"
|
||||||
"github.com/h44z/wg-portal/internal/app/api/v0/model"
|
"github.com/h44z/wg-portal/internal/app/api/v0/model"
|
||||||
"github.com/h44z/wg-portal/internal/config"
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed frontend_config.js.gotpl
|
//go:embed frontend_config.js.gotpl
|
||||||
var frontendJs embed.FS
|
var frontendJs embed.FS
|
||||||
|
|
||||||
|
type ControllerManager interface {
|
||||||
|
GetControllerNames() []config.BackendBase
|
||||||
|
}
|
||||||
|
|
||||||
type ConfigEndpoint struct {
|
type ConfigEndpoint struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
authenticator Authenticator
|
authenticator Authenticator
|
||||||
|
controllerMgr ControllerManager
|
||||||
|
|
||||||
tpl *respond.TemplateRenderer
|
tpl *respond.TemplateRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConfigEndpoint(cfg *config.Config, authenticator Authenticator) ConfigEndpoint {
|
func NewConfigEndpoint(cfg *config.Config, authenticator Authenticator, ctrlMgr ControllerManager) ConfigEndpoint {
|
||||||
ep := ConfigEndpoint{
|
ep := ConfigEndpoint{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
authenticator: authenticator,
|
authenticator: authenticator,
|
||||||
|
controllerMgr: ctrlMgr,
|
||||||
tpl: respond.NewTemplateRenderer(template.Must(template.ParseFS(frontendJs,
|
tpl: respond.NewTemplateRenderer(template.Must(template.ParseFS(frontendJs,
|
||||||
"frontend_config.js.gotpl"))),
|
"frontend_config.js.gotpl"))),
|
||||||
}
|
}
|
||||||
@@ -46,7 +53,7 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
|||||||
apiGroup := g.Mount("/config")
|
apiGroup := g.Mount("/config")
|
||||||
|
|
||||||
apiGroup.HandleFunc("GET /frontend.js", e.handleConfigJsGet())
|
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.
|
// handleConfigJsGet returns a gorm Handler function.
|
||||||
@@ -93,11 +100,50 @@ func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
|
|||||||
// @Router /config/settings [get]
|
// @Router /config/settings [get]
|
||||||
func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
|
func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
respond.JSON(w, http.StatusOK, model.Settings{
|
sessionUser := domain.GetUserInfo(r.Context())
|
||||||
MailLinkOnly: e.cfg.Mail.LinkOnly,
|
|
||||||
PersistentConfigSupported: e.cfg.Advanced.ConfigStoragePath != "",
|
controllerFn := func() []model.SettingsBackendNames {
|
||||||
SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed,
|
controllers := e.controllerMgr.GetControllerNames()
|
||||||
ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly,
|
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 deletes the peer with the given id.
|
||||||
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
|
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
|
||||||
// GetPeerConfig returns the peer configuration for the given id.
|
// 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 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 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 returns the peer stats for the given interface.
|
||||||
GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error)
|
GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error)
|
||||||
}
|
}
|
||||||
@@ -355,6 +355,7 @@ func (e PeerEndpoint) handleDelete() http.HandlerFunc {
|
|||||||
// @Summary Get peer configuration as string.
|
// @Summary Get peer configuration as string.
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "The peer identifier"
|
// @Param id path string true "The peer identifier"
|
||||||
|
// @Param style query string false "The configuration style"
|
||||||
// @Success 200 {object} string
|
// @Success 200 {object} string
|
||||||
// @Failure 400 {object} model.Error
|
// @Failure 400 {object} model.Error
|
||||||
// @Failure 500 {object} model.Error
|
// @Failure 500 {object} model.Error
|
||||||
@@ -369,7 +370,9 @@ func (e PeerEndpoint) handleConfigGet() http.HandlerFunc {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
respond.JSON(w, http.StatusInternalServerError, model.Error{
|
respond.JSON(w, http.StatusInternalServerError, model.Error{
|
||||||
Code: http.StatusInternalServerError, Message: err.Error(),
|
Code: http.StatusInternalServerError, Message: err.Error(),
|
||||||
@@ -397,6 +400,7 @@ func (e PeerEndpoint) handleConfigGet() http.HandlerFunc {
|
|||||||
// @Produce png
|
// @Produce png
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "The peer identifier"
|
// @Param id path string true "The peer identifier"
|
||||||
|
// @Param style query string false "The configuration style"
|
||||||
// @Success 200 {file} binary
|
// @Success 200 {file} binary
|
||||||
// @Failure 400 {object} model.Error
|
// @Failure 400 {object} model.Error
|
||||||
// @Failure 500 {object} model.Error
|
// @Failure 500 {object} model.Error
|
||||||
@@ -411,7 +415,9 @@ func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
respond.JSON(w, http.StatusInternalServerError, model.Error{
|
respond.JSON(w, http.StatusInternalServerError, model.Error{
|
||||||
Code: http.StatusInternalServerError, Message: err.Error(),
|
Code: http.StatusInternalServerError, Message: err.Error(),
|
||||||
@@ -438,6 +444,7 @@ func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc {
|
|||||||
// @Summary Send peer configuration via email.
|
// @Summary Send peer configuration via email.
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body model.PeerMailRequest true "The peer mail request data"
|
// @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"
|
// @Success 204 "No content if mail sending was successful"
|
||||||
// @Failure 400 {object} model.Error
|
// @Failure 400 {object} model.Error
|
||||||
// @Failure 500 {object} model.Error
|
// @Failure 500 {object} model.Error
|
||||||
@@ -460,11 +467,13 @@ func (e PeerEndpoint) handleEmailPost() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configStyle := e.getConfigStyle(r)
|
||||||
|
|
||||||
peerIds := make([]domain.PeerIdentifier, len(req.Identifiers))
|
peerIds := make([]domain.PeerIdentifier, len(req.Identifiers))
|
||||||
for i := range req.Identifiers {
|
for i := range req.Identifiers {
|
||||||
peerIds[i] = domain.PeerIdentifier(req.Identifiers[i])
|
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,
|
respond.JSON(w, http.StatusInternalServerError,
|
||||||
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||||
return
|
return
|
||||||
@@ -504,3 +513,11 @@ func (e PeerEndpoint) handleStatsGet() http.HandlerFunc {
|
|||||||
respond.JSON(w, http.StatusOK, model.NewPeerStats(e.cfg.Statistics.CollectPeerData, stats))
|
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.
|
// 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 {
|
func (h AuthenticationHandler) UserIdMatch(idParameter string) func(next http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
|
@@ -31,6 +31,8 @@ type SessionData struct {
|
|||||||
OauthProvider string
|
OauthProvider string
|
||||||
OauthReturnTo string
|
OauthReturnTo string
|
||||||
|
|
||||||
|
WebAuthnData string
|
||||||
|
|
||||||
CsrfToken string
|
CsrfToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -6,8 +6,17 @@ type Error struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Settings struct {
|
type Settings struct {
|
||||||
MailLinkOnly bool `json:"MailLinkOnly"`
|
MailLinkOnly bool `json:"MailLinkOnly"`
|
||||||
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
|
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
|
||||||
SelfProvisioning bool `json:"SelfProvisioning"`
|
SelfProvisioning bool `json:"SelfProvisioning"`
|
||||||
ApiAdminOnly bool `json:"ApiAdminOnly"`
|
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
|
package model
|
||||||
|
|
||||||
import "github.com/h44z/wg-portal/internal/domain"
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
type LoginProviderInfo struct {
|
type LoginProviderInfo struct {
|
||||||
Identifier string `json:"Identifier" example:"google"`
|
Identifier string `json:"Identifier" example:"google"`
|
||||||
@@ -39,3 +44,32 @@ type OauthInitiationResponse struct {
|
|||||||
RedirectUrl string
|
RedirectUrl string
|
||||||
State 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"
|
"time"
|
||||||
|
|
||||||
"github.com/h44z/wg-portal/internal"
|
"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/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ type Interface struct {
|
|||||||
Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0
|
Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0
|
||||||
DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface
|
DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface
|
||||||
Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any'
|
Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any'
|
||||||
|
Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ...
|
||||||
PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface
|
PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface
|
||||||
PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
|
PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
|
||||||
Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
|
Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
|
||||||
@@ -57,6 +59,7 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
|
|||||||
Identifier: string(src.Identifier),
|
Identifier: string(src.Identifier),
|
||||||
DisplayName: src.DisplayName,
|
DisplayName: src.DisplayName,
|
||||||
Mode: string(src.Type),
|
Mode: string(src.Type),
|
||||||
|
Backend: string(src.Backend),
|
||||||
PrivateKey: src.PrivateKey,
|
PrivateKey: src.PrivateKey,
|
||||||
PublicKey: src.PublicKey,
|
PublicKey: src.PublicKey,
|
||||||
Disabled: src.IsDisabled(),
|
Disabled: src.IsDisabled(),
|
||||||
@@ -92,6 +95,10 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
|
|||||||
Filename: src.GetConfigFileName(),
|
Filename: src.GetConfigFileName(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if iface.Backend == "" {
|
||||||
|
iface.Backend = config.LocalBackendName // default to local backend
|
||||||
|
}
|
||||||
|
|
||||||
if len(peers) > 0 {
|
if len(peers) > 0 {
|
||||||
iface.TotalPeers = len(peers)
|
iface.TotalPeers = len(peers)
|
||||||
|
|
||||||
@@ -146,6 +153,7 @@ func NewDomainInterface(src *Interface) *domain.Interface {
|
|||||||
SaveConfig: src.SaveConfig,
|
SaveConfig: src.SaveConfig,
|
||||||
DisplayName: src.DisplayName,
|
DisplayName: src.DisplayName,
|
||||||
Type: domain.InterfaceType(src.Mode),
|
Type: domain.InterfaceType(src.Mode),
|
||||||
|
Backend: domain.InterfaceBackend(src.Backend),
|
||||||
DriverType: "", // currently unused
|
DriverType: "", // currently unused
|
||||||
Disabled: nil, // set below
|
Disabled: nil, // set below
|
||||||
DisabledReason: src.DisabledReason,
|
DisabledReason: src.DisabledReason,
|
||||||
|
@@ -43,6 +43,7 @@ type Peer struct {
|
|||||||
Identifier string `json:"Identifier" example:"super_nice_peer"` // peer unique identifier
|
Identifier string `json:"Identifier" example:"super_nice_peer"` // peer unique identifier
|
||||||
DisplayName string `json:"DisplayName"` // a nice display name/ description for the peer
|
DisplayName string `json:"DisplayName"` // a nice display name/ description for the peer
|
||||||
UserIdentifier string `json:"UserIdentifier"` // the owner
|
UserIdentifier string `json:"UserIdentifier"` // the owner
|
||||||
|
UserDisplayName string `json:"UserDisplayName"` // the owner display name
|
||||||
InterfaceIdentifier string `json:"InterfaceIdentifier"` // the interface id
|
InterfaceIdentifier string `json:"InterfaceIdentifier"` // the interface id
|
||||||
Disabled bool `json:"Disabled"` // flag that specifies if the peer is enabled (up) or not (down)
|
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
|
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 {
|
func NewPeer(src *domain.Peer) *Peer {
|
||||||
return &Peer{
|
p := &Peer{
|
||||||
Identifier: string(src.Identifier),
|
Identifier: string(src.Identifier),
|
||||||
DisplayName: src.DisplayName,
|
DisplayName: src.DisplayName,
|
||||||
UserIdentifier: string(src.UserIdentifier),
|
UserIdentifier: string(src.UserIdentifier),
|
||||||
@@ -111,6 +112,12 @@ func NewPeer(src *domain.Peer) *Peer {
|
|||||||
PostDown: ConfigOptionFromDomain(src.Interface.PostDown),
|
PostDown: ConfigOptionFromDomain(src.Interface.PostDown),
|
||||||
Filename: src.GetConfigFileName(),
|
Filename: src.GetConfigFileName(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if src.User != nil {
|
||||||
|
p.UserDisplayName = src.User.DisplayName()
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPeers(src []domain.Peer) []Peer {
|
func NewPeers(src []domain.Peer) []Peer {
|
||||||
@@ -198,7 +205,7 @@ func NewPeerStats(enabled bool, src []domain.PeerStatus) *PeerStats {
|
|||||||
|
|
||||||
for _, srcStat := range src {
|
for _, srcStat := range src {
|
||||||
stats[string(srcStat.PeerId)] = PeerStatData{
|
stats[string(srcStat.PeerId)] = PeerStatData{
|
||||||
IsConnected: srcStat.IsConnected(),
|
IsConnected: srcStat.IsConnected,
|
||||||
IsPingable: srcStat.IsPingable,
|
IsPingable: srcStat.IsPingable,
|
||||||
LastPing: srcStat.LastPing,
|
LastPing: srcStat.LastPing,
|
||||||
BytesReceived: srcStat.BytesReceived,
|
BytesReceived: srcStat.BytesReceived,
|
||||||
|
@@ -23,8 +23,8 @@ type ProvisioningServicePeerManagerRepo interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProvisioningServiceConfigFileManagerRepo interface {
|
type ProvisioningServiceConfigFileManagerRepo interface {
|
||||||
GetPeerConfig(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) (io.Reader, error)
|
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProvisioningService struct {
|
type ProvisioningService struct {
|
||||||
@@ -96,7 +96,7 @@ func (p ProvisioningService) GetPeerConfig(ctx context.Context, peerId domain.Pe
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
peerCfgReader, err := p.configFiles.GetPeerConfig(ctx, peer.Identifier)
|
peerCfgReader, err := p.configFiles.GetPeerConfig(ctx, peer.Identifier, domain.ConfigStyleWgQuick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ func (p ProvisioningService) GetPeerQrPng(ctx context.Context, peerId domain.Pee
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
peerCfgQrReader, err := p.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier)
|
peerCfgQrReader, err := p.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier, domain.ConfigStyleWgQuick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -162,7 +162,11 @@ func (p ProvisioningService) NewPeer(ctx context.Context, req models.Provisionin
|
|||||||
if req.PresharedKey != "" {
|
if req.PresharedKey != "" {
|
||||||
peer.PresharedKey = domain.PreSharedKey(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
|
// save new peer
|
||||||
peer, err = p.peers.CreatePeer(ctx, peer)
|
peer, err = p.peers.CreatePeer(ctx, peer)
|
||||||
|
@@ -68,6 +68,10 @@ type ProvisioningRequest struct {
|
|||||||
// If no user identifier is set, the authenticated user is used.
|
// If no user identifier is set, the authenticated user is used.
|
||||||
UserIdentifier string `json:"UserIdentifier" example:"uid-1234567"`
|
UserIdentifier string `json:"UserIdentifier" example:"uid-1234567"`
|
||||||
|
|
||||||
|
// DisplayName is an optional name for the new peer.
|
||||||
|
// If unset, a default template value (e.g., "API Peer ...") will be assigned.
|
||||||
|
DisplayName string `json:"DisplayName" example:"API Peer xyz" binding:"omitempty"`
|
||||||
|
|
||||||
// PublicKey is the optional public key of the peer. If no public key is set, a new key pair is generated.
|
// PublicKey is the optional public key of the peer. If no public key is set, a new key pair is generated.
|
||||||
PublicKey string `json:"PublicKey" example:"xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=" binding:"omitempty,len=44"`
|
PublicKey string `json:"PublicKey" example:"xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=" binding:"omitempty,len=44"`
|
||||||
// PresharedKey is the optional pre-shared key of the peer. If no pre-shared key is set, a new key is generated.
|
// PresharedKey is the optional pre-shared key of the peer. If no pre-shared key is set, a new key is generated.
|
||||||
|
@@ -46,14 +46,18 @@ func Initialize(
|
|||||||
users: users,
|
users: users,
|
||||||
}
|
}
|
||||||
|
|
||||||
startupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
startupContext, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Switch to admin user context
|
// Switch to admin user context
|
||||||
startupContext = domain.SetUserInfo(startupContext, domain.SystemAdminContextUserInfo())
|
startupContext = domain.SetUserInfo(startupContext, domain.SystemAdminContextUserInfo())
|
||||||
|
|
||||||
if err := a.createDefaultUser(startupContext); err != nil {
|
if !cfg.Core.AdminUserDisabled {
|
||||||
return fmt.Errorf("failed to create default user: %w", err)
|
if err := a.createDefaultUser(startupContext); err != nil {
|
||||||
|
return fmt.Errorf("failed to create default user: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog.Info("Local Admin user disabled!")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := a.importNewInterfaces(startupContext); err != nil {
|
if err := a.importNewInterfaces(startupContext); err != nil {
|
||||||
|
@@ -93,6 +93,8 @@ type Authenticator struct {
|
|||||||
// URL prefix for the callback endpoints, this is a combination of the external URL and the API prefix
|
// URL prefix for the callback endpoints, this is a combination of the external URL and the API prefix
|
||||||
callbackUrlPrefix string
|
callbackUrlPrefix string
|
||||||
|
|
||||||
|
callbackUrl *url.URL
|
||||||
|
|
||||||
users UserManager
|
users UserManager
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,82 +104,152 @@ func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserM
|
|||||||
error,
|
error,
|
||||||
) {
|
) {
|
||||||
a := &Authenticator{
|
a := &Authenticator{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
bus: bus,
|
bus: bus,
|
||||||
users: users,
|
users: users,
|
||||||
callbackUrlPrefix: fmt.Sprintf("%s/api/v0", extUrl),
|
callbackUrlPrefix: fmt.Sprintf("%s/api/v0", extUrl),
|
||||||
|
oauthAuthenticators: make(map[string]AuthenticatorOauth, len(cfg.OpenIDConnect)+len(cfg.OAuth)),
|
||||||
|
ldapAuthenticators: make(map[string]AuthenticatorLdap, len(cfg.Ldap)),
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
parsedExtUrl, err := url.Parse(a.callbackUrlPrefix)
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
err := a.setupExternalAuthProviders(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to parse external URL: %w", err)
|
||||||
}
|
}
|
||||||
|
a.callbackUrl = parsedExtUrl
|
||||||
|
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Authenticator) setupExternalAuthProviders(ctx context.Context) error {
|
// StartBackgroundJobs starts the background jobs for the authenticator.
|
||||||
extUrl, err := url.Parse(a.callbackUrlPrefix)
|
// It sets up the external authentication providers (OIDC, OAuth, LDAP) and retries in case of errors.
|
||||||
if err != nil {
|
func (a *Authenticator) StartBackgroundJobs(ctx context.Context) {
|
||||||
return fmt.Errorf("failed to parse external url: %w", err)
|
go func() {
|
||||||
}
|
slog.Debug("setting up external auth providers...")
|
||||||
|
|
||||||
a.oauthAuthenticators = make(map[string]AuthenticatorOauth, len(a.cfg.OpenIDConnect)+len(a.cfg.OAuth))
|
// Initialize local copies of authentication providers to allow retry in case of errors
|
||||||
a.ldapAuthenticators = make(map[string]AuthenticatorLdap, len(a.cfg.Ldap))
|
oidcQueue := a.cfg.OpenIDConnect
|
||||||
|
oauthQueue := a.cfg.OAuth
|
||||||
|
ldapQueue := a.cfg.Ldap
|
||||||
|
|
||||||
for i := range a.cfg.OpenIDConnect { // OIDC
|
// Immediate attempt
|
||||||
providerCfg := &a.cfg.OpenIDConnect[i]
|
failedOidc, failedOauth, failedLdap := a.setupExternalAuthProviders(oidcQueue, oauthQueue, ldapQueue)
|
||||||
|
if len(failedOidc) == 0 && len(failedOauth) == 0 && len(failedLdap) == 0 {
|
||||||
|
slog.Info("successfully setup all external auth providers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare for retries with only the failed ones
|
||||||
|
oidcQueue = failedOidc
|
||||||
|
oauthQueue = failedOauth
|
||||||
|
ldapQueue = failedLdap
|
||||||
|
slog.Warn("failed to setup some external auth providers, retrying in 30 seconds",
|
||||||
|
"failedOidc", len(failedOidc), "failedOauth", len(failedOauth), "failedLdap", len(failedLdap))
|
||||||
|
|
||||||
|
ticker := time.NewTicker(30 * time.Second) // Ticker for delay between retries
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
failedOidc, failedOauth, failedLdap := a.setupExternalAuthProviders(oidcQueue, oauthQueue, ldapQueue)
|
||||||
|
if len(failedOidc) > 0 || len(failedOauth) > 0 || len(failedLdap) > 0 {
|
||||||
|
slog.Warn("failed to setup some external auth providers, retrying in 30 seconds",
|
||||||
|
"failedOidc", len(failedOidc), "failedOauth", len(failedOauth), "failedLdap", len(failedLdap))
|
||||||
|
// Retry failed providers
|
||||||
|
oidcQueue = failedOidc
|
||||||
|
oauthQueue = failedOauth
|
||||||
|
ldapQueue = failedLdap
|
||||||
|
} else {
|
||||||
|
slog.Info("successfully setup all external auth providers")
|
||||||
|
return // Exit goroutine if all providers are set up successfully
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("context cancelled, stopping setup of external auth providers")
|
||||||
|
return // Exit goroutine if context is cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Authenticator) setupExternalAuthProviders(
|
||||||
|
oidc []config.OpenIDConnectProvider,
|
||||||
|
oauth []config.OAuthProvider,
|
||||||
|
ldap []config.LdapProvider,
|
||||||
|
) (
|
||||||
|
[]config.OpenIDConnectProvider,
|
||||||
|
[]config.OAuthProvider,
|
||||||
|
[]config.LdapProvider,
|
||||||
|
) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var failedOidc []config.OpenIDConnectProvider
|
||||||
|
var failedOauth []config.OAuthProvider
|
||||||
|
var failedLdap []config.LdapProvider
|
||||||
|
|
||||||
|
for i := range oidc { // OIDC
|
||||||
|
providerCfg := &oidc[i]
|
||||||
providerId := strings.ToLower(providerCfg.ProviderName)
|
providerId := strings.ToLower(providerCfg.ProviderName)
|
||||||
|
|
||||||
if _, exists := a.oauthAuthenticators[providerId]; exists {
|
if _, exists := a.oauthAuthenticators[providerId]; exists {
|
||||||
return fmt.Errorf("auth provider with name %s is already registerd", providerId)
|
// this is an unrecoverable error, we cannot register the same provider twice
|
||||||
|
slog.Error("OIDC auth provider is already registered", "name", providerId)
|
||||||
|
continue // skip this provider
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectUrl := *extUrl
|
redirectUrl := *a.callbackUrl
|
||||||
redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback")
|
redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback")
|
||||||
|
|
||||||
provider, err := newOidcAuthenticator(ctx, redirectUrl.String(), providerCfg)
|
provider, err := newOidcAuthenticator(ctx, redirectUrl.String(), providerCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to setup oidc authentication provider %s: %w", providerCfg.ProviderName, err)
|
failedOidc = append(failedOidc, oidc[i])
|
||||||
|
slog.Error("failed to setup oidc authentication provider", "name", providerId, "error", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
a.oauthAuthenticators[providerId] = provider
|
a.oauthAuthenticators[providerId] = provider
|
||||||
}
|
}
|
||||||
for i := range a.cfg.OAuth { // PLAIN OAUTH
|
for i := range oauth { // PLAIN OAUTH
|
||||||
providerCfg := &a.cfg.OAuth[i]
|
providerCfg := &oauth[i]
|
||||||
providerId := strings.ToLower(providerCfg.ProviderName)
|
providerId := strings.ToLower(providerCfg.ProviderName)
|
||||||
|
|
||||||
if _, exists := a.oauthAuthenticators[providerId]; exists {
|
if _, exists := a.oauthAuthenticators[providerId]; exists {
|
||||||
return fmt.Errorf("auth provider with name %s is already registerd", providerId)
|
// this is an unrecoverable error, we cannot register the same provider twice
|
||||||
|
slog.Error("OAUTH auth provider is already registered", "name", providerId)
|
||||||
|
continue // skip this provider
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectUrl := *extUrl
|
redirectUrl := *a.callbackUrl
|
||||||
redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback")
|
redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback")
|
||||||
|
|
||||||
provider, err := newPlainOauthAuthenticator(ctx, redirectUrl.String(), providerCfg)
|
provider, err := newPlainOauthAuthenticator(ctx, redirectUrl.String(), providerCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to setup oauth authentication provider %s: %w", providerId, err)
|
failedOauth = append(failedOauth, oauth[i])
|
||||||
|
slog.Error("failed to setup oauth authentication provider", "name", providerId, "error", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
a.oauthAuthenticators[providerId] = provider
|
a.oauthAuthenticators[providerId] = provider
|
||||||
}
|
}
|
||||||
for i := range a.cfg.Ldap { // LDAP
|
for i := range ldap { // LDAP
|
||||||
providerCfg := &a.cfg.Ldap[i]
|
providerCfg := &ldap[i]
|
||||||
providerId := strings.ToLower(providerCfg.URL)
|
providerId := strings.ToLower(providerCfg.URL)
|
||||||
|
|
||||||
if _, exists := a.ldapAuthenticators[providerId]; exists {
|
if _, exists := a.ldapAuthenticators[providerId]; exists {
|
||||||
return fmt.Errorf("auth provider with name %s is already registerd", providerId)
|
// this is an unrecoverable error, we cannot register the same provider twice
|
||||||
|
slog.Error("LDAP auth provider is already registered", "name", providerId)
|
||||||
|
continue // skip this provider
|
||||||
}
|
}
|
||||||
|
|
||||||
provider, err := newLdapAuthenticator(ctx, providerCfg)
|
provider, err := newLdapAuthenticator(ctx, providerCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to setup ldap authentication provider %s: %w", providerId, err)
|
failedLdap = append(failedLdap, ldap[i])
|
||||||
|
slog.Error("failed to setup ldap authentication provider", "name", providerId, "error", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
a.ldapAuthenticators[providerId] = provider
|
a.ldapAuthenticators[providerId] = provider
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return failedOidc, failedOauth, failedLdap
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetExternalLoginProviders returns a list of all available external login providers.
|
// GetExternalLoginProviders returns a list of all available external login providers.
|
||||||
@@ -302,12 +374,15 @@ func (a *Authenticator) passwordAuthentication(
|
|||||||
rawUserInfo, err := ldapAuth.GetUserInfo(context.Background(), identifier)
|
rawUserInfo, err := ldapAuth.GetUserInfo(context.Background(), identifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, domain.ErrNotFound) {
|
if !errors.Is(err, domain.ErrNotFound) {
|
||||||
slog.Warn("failed to fetch ldap user info", "identifier", identifier, "error", err)
|
slog.Warn("failed to fetch ldap user info",
|
||||||
|
"source", ldapAuth.GetName(), "identifier", identifier, "error", err)
|
||||||
}
|
}
|
||||||
continue // user not found / other ldap error
|
continue // user not found / other ldap error
|
||||||
}
|
}
|
||||||
ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo)
|
ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Error("failed to parse ldap user info",
|
||||||
|
"source", ldapAuth.GetName(), "identifier", identifier, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,10 +395,14 @@ func (a *Authenticator) passwordAuthentication(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if userSource == "" {
|
if userSource == "" {
|
||||||
|
slog.Warn("no user source found for user",
|
||||||
|
"identifier", identifier, "ldapProviderCount", len(a.ldapAuthenticators), "inDb", userInDatabase)
|
||||||
return nil, errors.New("user not found")
|
return nil, errors.New("user not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if userSource == domain.UserSourceLdap && ldapProvider == nil {
|
if userSource == domain.UserSourceLdap && ldapProvider == nil {
|
||||||
|
slog.Warn("no ldap provider found for user",
|
||||||
|
"identifier", identifier, "ldapProviderCount", len(a.ldapAuthenticators), "inDb", userInDatabase)
|
||||||
return nil, errors.New("ldap provider not found")
|
return nil, errors.New("ldap provider not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +513,10 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
|
|||||||
return nil, fmt.Errorf("unable to parse user information: %w", err)
|
return nil, fmt.Errorf("unable to parse user information: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !isDomainAllowed(userInfo.Email, oauthProvider.GetAllowedDomains()) {
|
||||||
|
return nil, fmt.Errorf("user %s is not in allowed domains", userInfo.Email)
|
||||||
|
}
|
||||||
|
|
||||||
ctx = domain.SetUserInfo(ctx,
|
ctx = domain.SetUserInfo(ctx,
|
||||||
domain.SystemAdminContextUserInfo()) // switch to admin user context to check if user exists
|
domain.SystemAdminContextUserInfo()) // switch to admin user context to check if user exists
|
||||||
user, err := a.processUserInfo(ctx, userInfo, domain.UserSourceOauth, oauthProvider.GetName(),
|
user, err := a.processUserInfo(ctx, userInfo, domain.UserSourceOauth, oauthProvider.GetName(),
|
||||||
@@ -450,10 +533,6 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
|
|||||||
return nil, fmt.Errorf("unable to process user information: %w", err)
|
return nil, fmt.Errorf("unable to process user information: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isDomainAllowed(userInfo.Email, oauthProvider.GetAllowedDomains()) {
|
|
||||||
return nil, fmt.Errorf("user is not in allowed domains: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.IsLocked() || user.IsDisabled() {
|
if user.IsLocked() || user.IsDisabled() {
|
||||||
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
|
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
|
@@ -54,7 +54,7 @@ func (l LdapAuthenticator) PlaintextAuthentication(userId domain.UserIdentifier,
|
|||||||
|
|
||||||
attrs := []string{"dn"}
|
attrs := []string{"dn"}
|
||||||
|
|
||||||
loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", string(userId), -1)
|
loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", ldap.EscapeFilter(string(userId)), -1)
|
||||||
searchRequest := ldap.NewSearchRequest(
|
searchRequest := ldap.NewSearchRequest(
|
||||||
l.cfg.BaseDN,
|
l.cfg.BaseDN,
|
||||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit
|
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit
|
||||||
@@ -100,7 +100,7 @@ func (l LdapAuthenticator) GetUserInfo(_ context.Context, userId domain.UserIden
|
|||||||
|
|
||||||
attrs := internal.LdapSearchAttributes(&l.cfg.FieldMap)
|
attrs := internal.LdapSearchAttributes(&l.cfg.FieldMap)
|
||||||
|
|
||||||
loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", string(userId), -1)
|
loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", ldap.EscapeFilter(string(userId)), -1)
|
||||||
searchRequest := ldap.NewSearchRequest(
|
searchRequest := ldap.NewSearchRequest(
|
||||||
l.cfg.BaseDN,
|
l.cfg.BaseDN,
|
||||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit
|
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit
|
||||||
@@ -113,10 +113,13 @@ func (l LdapAuthenticator) GetUserInfo(_ context.Context, userId domain.UserIden
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(sr.Entries) == 0 {
|
if len(sr.Entries) == 0 {
|
||||||
|
slog.Debug("LDAP user not found", "source", l.GetName(), "userId", userId, "filter", loginFilter)
|
||||||
return nil, domain.ErrNotFound
|
return nil, domain.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sr.Entries) > 1 {
|
if len(sr.Entries) > 1 {
|
||||||
|
slog.Debug("LDAP user not unique",
|
||||||
|
"source", l.GetName(), "userId", userId, "filter", loginFilter, "entries", len(sr.Entries))
|
||||||
return nil, domain.ErrNotUnique
|
return nil, domain.ErrNotUnique
|
||||||
}
|
}
|
||||||
|
|
||||||
|
301
internal/app/auth/webauthn.go
Normal file
301
internal/app/auth/webauthn.go
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
|
||||||
|
"github.com/h44z/wg-portal/internal/app"
|
||||||
|
"github.com/h44z/wg-portal/internal/app/audit"
|
||||||
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebAuthnUserManager interface {
|
||||||
|
// GetUser returns a user by its identifier.
|
||||||
|
GetUser(context.Context, domain.UserIdentifier) (*domain.User, error)
|
||||||
|
// GetUserByWebAuthnCredential returns a user by its WebAuthn ID.
|
||||||
|
GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error)
|
||||||
|
// UpdateUser updates an existing user in the database.
|
||||||
|
UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebAuthnAuthenticator struct {
|
||||||
|
webAuthn *webauthn.WebAuthn
|
||||||
|
users WebAuthnUserManager
|
||||||
|
bus EventBus
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebAuthnAuthenticator(cfg *config.Config, bus EventBus, users WebAuthnUserManager) (
|
||||||
|
*WebAuthnAuthenticator,
|
||||||
|
error,
|
||||||
|
) {
|
||||||
|
if !cfg.Auth.WebAuthn.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
extUrl, err := url.Parse(cfg.Web.ExternalUrl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("failed to parse external URL - required for WebAuthn RP ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
rpId := extUrl.Hostname()
|
||||||
|
if rpId == "" {
|
||||||
|
return nil, errors.New("failed to determine Webauthn RPID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the WebAuthn authenticator with the provided configuration
|
||||||
|
awCfg := &webauthn.Config{
|
||||||
|
RPID: rpId,
|
||||||
|
RPDisplayName: cfg.Web.SiteTitle,
|
||||||
|
RPOrigins: []string{cfg.Web.ExternalUrl},
|
||||||
|
}
|
||||||
|
|
||||||
|
webAuthn, err := webauthn.New(awCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create Webauthn instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WebAuthnAuthenticator{
|
||||||
|
webAuthn: webAuthn,
|
||||||
|
users: users,
|
||||||
|
bus: bus,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *WebAuthnAuthenticator) Enabled() bool {
|
||||||
|
return a != nil && a.webAuthn != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *WebAuthnAuthenticator) StartWebAuthnRegistration(ctx context.Context, userId domain.UserIdentifier) (
|
||||||
|
optionsAsJSON []byte,
|
||||||
|
sessionDataAsJSON []byte,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
user, err := a.users.GetUser(ctx, userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
if user.IsLocked() || user.IsDisabled() {
|
||||||
|
return nil, nil, errors.New("user is locked") // adding passkey to locked user is not allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.WebAuthnId == "" {
|
||||||
|
user.GenerateWebAuthnId()
|
||||||
|
user, err = a.users.UpdateUser(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to store webauthn id to user: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options, sessionData, err := a.webAuthn.BeginRegistration(user,
|
||||||
|
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsAsJSON, err = json.Marshal(options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to marshal webauthn options to JSON: %w", err)
|
||||||
|
}
|
||||||
|
sessionDataAsJSON, err = json.Marshal(sessionData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to marshal webauthn session data to JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return optionsAsJSON, sessionDataAsJSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *WebAuthnAuthenticator) FinishWebAuthnRegistration(
|
||||||
|
ctx context.Context,
|
||||||
|
userId domain.UserIdentifier,
|
||||||
|
name string,
|
||||||
|
sessionDataAsJSON []byte,
|
||||||
|
r *http.Request,
|
||||||
|
) ([]domain.UserWebauthnCredential, error) {
|
||||||
|
user, err := a.users.GetUser(ctx, userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
if user.IsLocked() || user.IsDisabled() {
|
||||||
|
return nil, errors.New("user is locked") // adding passkey to locked user is not allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
var webAuthnData webauthn.SessionData
|
||||||
|
err = json.Unmarshal(sessionDataAsJSON, &webAuthnData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal webauthn session data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := a.webAuthn.FinishRegistration(user, webAuthnData, r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("Passkey %d", len(user.WebAuthnCredentialList)+1) // fallback name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the credential to the user
|
||||||
|
err = user.AddCredential(userId, name, *credential)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err = a.users.UpdateUser(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.WebAuthnCredentialList, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *WebAuthnAuthenticator) GetCredentials(
|
||||||
|
ctx context.Context,
|
||||||
|
userId domain.UserIdentifier,
|
||||||
|
) ([]domain.UserWebauthnCredential, error) {
|
||||||
|
user, err := a.users.GetUser(ctx, userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.WebAuthnCredentialList, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *WebAuthnAuthenticator) RemoveCredential(
|
||||||
|
ctx context.Context,
|
||||||
|
userId domain.UserIdentifier,
|
||||||
|
credentialIdBase64 string,
|
||||||
|
) ([]domain.UserWebauthnCredential, error) {
|
||||||
|
user, err := a.users.GetUser(ctx, userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user.RemoveCredential(credentialIdBase64)
|
||||||
|
user, err = a.users.UpdateUser(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.WebAuthnCredentialList, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *WebAuthnAuthenticator) UpdateCredential(
|
||||||
|
ctx context.Context,
|
||||||
|
userId domain.UserIdentifier,
|
||||||
|
credentialIdBase64 string,
|
||||||
|
name string,
|
||||||
|
) ([]domain.UserWebauthnCredential, error) {
|
||||||
|
user, err := a.users.GetUser(ctx, userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = user.UpdateCredential(credentialIdBase64, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err = a.users.UpdateUser(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.WebAuthnCredentialList, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *WebAuthnAuthenticator) StartWebAuthnLogin(_ context.Context) (
|
||||||
|
optionsAsJSON []byte,
|
||||||
|
sessionDataAsJSON []byte,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
options, sessionData, err := a.webAuthn.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to begin WebAuthn login: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsAsJSON, err = json.Marshal(options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to marshal webauthn options to JSON: %w", err)
|
||||||
|
}
|
||||||
|
sessionDataAsJSON, err = json.Marshal(sessionData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to marshal webauthn session data to JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return optionsAsJSON, sessionDataAsJSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *WebAuthnAuthenticator) FinishWebAuthnLogin(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionDataAsJSON []byte,
|
||||||
|
r *http.Request,
|
||||||
|
) (*domain.User, error) {
|
||||||
|
|
||||||
|
var webAuthnData webauthn.SessionData
|
||||||
|
err := json.Unmarshal(sessionDataAsJSON, &webAuthnData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal webauthn session data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// switch to admin context for user lookup
|
||||||
|
ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo())
|
||||||
|
|
||||||
|
credential, err := a.webAuthn.FinishDiscoverableLogin(a.findUserForWebAuthnSecretFn(ctx), webAuthnData, r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the user by the WebAuthn ID
|
||||||
|
user, err := a.users.GetUserByWebAuthnCredential(ctx,
|
||||||
|
base64.StdEncoding.EncodeToString(credential.ID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user by webauthn credential: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.IsLocked() || user.IsDisabled() {
|
||||||
|
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
|
||||||
|
Ctx: ctx,
|
||||||
|
Source: "passkey",
|
||||||
|
Event: audit.AuthEvent{
|
||||||
|
Username: string(user.Identifier), Error: "User is locked",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return nil, errors.New("user is locked") // login with passkey is not allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
a.bus.Publish(app.TopicAuthLogin, user.Identifier)
|
||||||
|
a.bus.Publish(app.TopicAuditLoginSuccess, domain.AuditEventWrapper[audit.AuthEvent]{
|
||||||
|
Ctx: ctx,
|
||||||
|
Source: "passkey",
|
||||||
|
Event: audit.AuthEvent{
|
||||||
|
Username: string(user.Identifier),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *WebAuthnAuthenticator) findUserForWebAuthnSecretFn(ctx context.Context) func(rawID, userHandle []byte) (
|
||||||
|
user webauthn.User,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
return func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||||
|
// Find the user by the WebAuthn ID
|
||||||
|
user, err := a.users.GetUserByWebAuthnCredential(ctx, base64.StdEncoding.EncodeToString(rawID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user by webauthn credential: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
}
|
@@ -46,7 +46,7 @@ type TemplateRenderer interface {
|
|||||||
// GetInterfaceConfig returns the configuration file for the given interface.
|
// GetInterfaceConfig returns the configuration file for the given interface.
|
||||||
GetInterfaceConfig(iface *domain.Interface, peers []domain.Peer) (io.Reader, error)
|
GetInterfaceConfig(iface *domain.Interface, peers []domain.Peer) (io.Reader, error)
|
||||||
// GetPeerConfig returns the configuration file for the given peer.
|
// GetPeerConfig returns the configuration file for the given peer.
|
||||||
GetPeerConfig(peer *domain.Peer) (io.Reader, error)
|
GetPeerConfig(peer *domain.Peer, style string) (io.Reader, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventBus interface {
|
type EventBus interface {
|
||||||
@@ -186,7 +186,7 @@ func (m Manager) GetInterfaceConfig(ctx context.Context, id domain.InterfaceIden
|
|||||||
|
|
||||||
// GetPeerConfig returns the configuration file for the given peer.
|
// GetPeerConfig returns the configuration file for the given peer.
|
||||||
// The file is structured in wg-quick format.
|
// The file is structured in wg-quick format.
|
||||||
func (m Manager) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) {
|
func (m Manager) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) {
|
||||||
peer, err := m.wg.GetPeer(ctx, id)
|
peer, err := m.wg.GetPeer(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch peer %s: %w", id, err)
|
return nil, fmt.Errorf("failed to fetch peer %s: %w", id, err)
|
||||||
@@ -196,11 +196,11 @@ func (m Manager) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (i
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.tplHandler.GetPeerConfig(peer)
|
return m.tplHandler.GetPeerConfig(peer, style)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPeerConfigQrCode returns a QR code image containing the configuration for the given peer.
|
// GetPeerConfigQrCode returns a QR code image containing the configuration for the given peer.
|
||||||
func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) {
|
func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) {
|
||||||
peer, err := m.wg.GetPeer(ctx, id)
|
peer, err := m.wg.GetPeer(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch peer %s: %w", id, err)
|
return nil, fmt.Errorf("failed to fetch peer %s: %w", id, err)
|
||||||
@@ -210,7 +210,7 @@ func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifi
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgData, err := m.tplHandler.GetPeerConfig(peer)
|
cfgData, err := m.tplHandler.GetPeerConfig(peer, style)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get peer config for %s: %w", id, err)
|
return nil, fmt.Errorf("failed to get peer config for %s: %w", id, err)
|
||||||
}
|
}
|
||||||
|
@@ -55,11 +55,12 @@ func (c TemplateHandler) GetInterfaceConfig(cfg *domain.Interface, peers []domai
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPeerConfig returns the rendered configuration file for a WireGuard peer.
|
// GetPeerConfig returns the rendered configuration file for a WireGuard peer.
|
||||||
func (c TemplateHandler) GetPeerConfig(peer *domain.Peer) (io.Reader, error) {
|
func (c TemplateHandler) GetPeerConfig(peer *domain.Peer, style string) (io.Reader, error) {
|
||||||
var tplBuff bytes.Buffer
|
var tplBuff bytes.Buffer
|
||||||
|
|
||||||
err := c.templates.ExecuteTemplate(&tplBuff, "wg_peer.tpl", map[string]any{
|
err := c.templates.ExecuteTemplate(&tplBuff, "wg_peer.tpl", map[string]any{
|
||||||
"Peer": peer,
|
"Style": style,
|
||||||
|
"Peer": peer,
|
||||||
"Portal": map[string]any{
|
"Portal": map[string]any{
|
||||||
"Version": "unknown",
|
"Version": "unknown",
|
||||||
},
|
},
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
# AUTOGENERATED FILE - DO NOT EDIT
|
# AUTOGENERATED FILE - DO NOT EDIT
|
||||||
# This file uses wg-quick format.
|
# This file uses {{ .Style }} format.
|
||||||
|
{{- if eq .Style "wgquick"}}
|
||||||
# See https://man7.org/linux/man-pages/man8/wg-quick.8.html#CONFIGURATION
|
# See https://man7.org/linux/man-pages/man8/wg-quick.8.html#CONFIGURATION
|
||||||
|
{{- end}}
|
||||||
# Lines starting with the -WGP- tag are used by
|
# Lines starting with the -WGP- tag are used by
|
||||||
# the WireGuard Portal configuration parser.
|
# the WireGuard Portal configuration parser.
|
||||||
|
|
||||||
@@ -21,22 +23,27 @@
|
|||||||
|
|
||||||
# Core settings
|
# Core settings
|
||||||
PrivateKey = {{ .Peer.Interface.KeyPair.PrivateKey }}
|
PrivateKey = {{ .Peer.Interface.KeyPair.PrivateKey }}
|
||||||
|
{{- if eq .Style "wgquick"}}
|
||||||
Address = {{ CidrsToString .Peer.Interface.Addresses }}
|
Address = {{ CidrsToString .Peer.Interface.Addresses }}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
# Misc. settings (optional)
|
# Misc. settings (optional)
|
||||||
|
{{- if eq .Style "wgquick"}}
|
||||||
{{- if .Peer.Interface.DnsStr.GetValue}}
|
{{- if .Peer.Interface.DnsStr.GetValue}}
|
||||||
DNS = {{ .Peer.Interface.DnsStr.GetValue }} {{- if .Peer.Interface.DnsSearchStr.GetValue}}, {{ .Peer.Interface.DnsSearchStr.GetValue }} {{- end}}
|
DNS = {{ .Peer.Interface.DnsStr.GetValue }} {{- if .Peer.Interface.DnsSearchStr.GetValue}}, {{ .Peer.Interface.DnsSearchStr.GetValue }} {{- end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if ne .Peer.Interface.Mtu.GetValue 0}}
|
{{- if ne .Peer.Interface.Mtu.GetValue 0}}
|
||||||
MTU = {{ .Peer.Interface.Mtu.GetValue }}
|
MTU = {{ .Peer.Interface.Mtu.GetValue }}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if ne .Peer.Interface.FirewallMark.GetValue 0}}
|
|
||||||
FwMark = {{ .Peer.Interface.FirewallMark.GetValue }}
|
|
||||||
{{- end}}
|
|
||||||
{{- if ne .Peer.Interface.RoutingTable.GetValue ""}}
|
{{- if ne .Peer.Interface.RoutingTable.GetValue ""}}
|
||||||
Table = {{ .Peer.Interface.RoutingTable.GetValue }}
|
Table = {{ .Peer.Interface.RoutingTable.GetValue }}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if ne .Peer.Interface.FirewallMark.GetValue 0}}
|
||||||
|
FwMark = {{ .Peer.Interface.FirewallMark.GetValue }}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
{{- if eq .Style "wgquick"}}
|
||||||
# Interface hooks (optional)
|
# Interface hooks (optional)
|
||||||
{{- if .Peer.Interface.PreUp.GetValue}}
|
{{- if .Peer.Interface.PreUp.GetValue}}
|
||||||
PreUp = {{ .Peer.Interface.PreUp.GetValue }}
|
PreUp = {{ .Peer.Interface.PreUp.GetValue }}
|
||||||
@@ -50,6 +57,7 @@ PreDown = {{ .Peer.Interface.PreDown.GetValue }}
|
|||||||
{{- if .Peer.Interface.PostDown.GetValue}}
|
{{- if .Peer.Interface.PostDown.GetValue}}
|
||||||
PostDown = {{ .Peer.Interface.PostDown.GetValue }}
|
PostDown = {{ .Peer.Interface.PostDown.GetValue }}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
[Peer]
|
[Peer]
|
||||||
PublicKey = {{ .Peer.EndpointPublicKey.GetValue }}
|
PublicKey = {{ .Peer.EndpointPublicKey.GetValue }}
|
||||||
|
@@ -36,6 +36,7 @@ const TopicPeerDeleted = "peer:deleted"
|
|||||||
const TopicPeerUpdated = "peer:updated"
|
const TopicPeerUpdated = "peer:updated"
|
||||||
const TopicPeerInterfaceUpdated = "peer:interface:updated"
|
const TopicPeerInterfaceUpdated = "peer:interface:updated"
|
||||||
const TopicPeerIdentifierUpdated = "peer:identifier:updated"
|
const TopicPeerIdentifierUpdated = "peer:identifier:updated"
|
||||||
|
const TopicPeerStateChanged = "peer:state:changed"
|
||||||
|
|
||||||
// endregion peer-events
|
// endregion peer-events
|
||||||
|
|
||||||
|
@@ -21,9 +21,9 @@ type ConfigFileManager interface {
|
|||||||
// GetInterfaceConfig returns the configuration for the given interface.
|
// GetInterfaceConfig returns the configuration for the given interface.
|
||||||
GetInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) (io.Reader, error)
|
GetInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) (io.Reader, error)
|
||||||
// GetPeerConfig returns the configuration for the given peer.
|
// GetPeerConfig returns the configuration for the given peer.
|
||||||
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 QR code for the given peer.
|
// GetPeerConfigQrCode returns the QR code for the given peer.
|
||||||
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
|
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserDatabaseRepo interface {
|
type UserDatabaseRepo interface {
|
||||||
@@ -71,7 +71,7 @@ func NewMailManager(
|
|||||||
users UserDatabaseRepo,
|
users UserDatabaseRepo,
|
||||||
wg WireguardDatabaseRepo,
|
wg WireguardDatabaseRepo,
|
||||||
) (*Manager, error) {
|
) (*Manager, error) {
|
||||||
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl)
|
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize template handler: %w", err)
|
return nil, fmt.Errorf("failed to initialize template handler: %w", err)
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ func NewMailManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendPeerEmail sends an email to the user linked to the given peers.
|
// SendPeerEmail sends an email to the user linked to the given peers.
|
||||||
func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error {
|
func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error {
|
||||||
for _, peerId := range peers {
|
for _, peerId := range peers {
|
||||||
peer, err := m.wg.GetPeer(ctx, peerId)
|
peer, err := m.wg.GetPeer(ctx, peerId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -123,7 +123,7 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...doma
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err = m.sendPeerEmail(ctx, linkOnly, user, peer)
|
err = m.sendPeerEmail(ctx, linkOnly, style, user, peer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send peer email for %s: %w", peerId, err)
|
return fmt.Errorf("failed to send peer email for %s: %w", peerId, err)
|
||||||
}
|
}
|
||||||
@@ -132,7 +132,13 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...doma
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) sendPeerEmail(ctx context.Context, linkOnly bool, user *domain.User, peer *domain.Peer) error {
|
func (m Manager) sendPeerEmail(
|
||||||
|
ctx context.Context,
|
||||||
|
linkOnly bool,
|
||||||
|
style string,
|
||||||
|
user *domain.User,
|
||||||
|
peer *domain.Peer,
|
||||||
|
) error {
|
||||||
qrName := "WireGuardQRCode.png"
|
qrName := "WireGuardQRCode.png"
|
||||||
configName := peer.GetConfigFileName()
|
configName := peer.GetConfigFileName()
|
||||||
|
|
||||||
@@ -148,12 +154,12 @@ func (m Manager) sendPeerEmail(ctx context.Context, linkOnly bool, user *domain.
|
|||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
peerConfig, err := m.configFiles.GetPeerConfig(ctx, peer.Identifier)
|
peerConfig, err := m.configFiles.GetPeerConfig(ctx, peer.Identifier, style)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to fetch peer config for %s: %w", peer.Identifier, err)
|
return fmt.Errorf("failed to fetch peer config for %s: %w", peer.Identifier, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
peerConfigQr, err := m.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier)
|
peerConfigQr, err := m.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier, style)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to fetch peer config QR code for %s: %w", peer.Identifier, err)
|
return fmt.Errorf("failed to fetch peer config QR code for %s: %w", peer.Identifier, err)
|
||||||
}
|
}
|
||||||
|
@@ -17,11 +17,12 @@ var TemplateFiles embed.FS
|
|||||||
// TemplateHandler is a struct that holds the html and text templates.
|
// TemplateHandler is a struct that holds the html and text templates.
|
||||||
type TemplateHandler struct {
|
type TemplateHandler struct {
|
||||||
portalUrl string
|
portalUrl string
|
||||||
|
portalName string
|
||||||
htmlTemplates *htmlTemplate.Template
|
htmlTemplates *htmlTemplate.Template
|
||||||
textTemplates *template.Template
|
textTemplates *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTemplateHandler(portalUrl string) (*TemplateHandler, error) {
|
func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error) {
|
||||||
htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml")
|
htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse html template files: %w", err)
|
return nil, fmt.Errorf("failed to parse html template files: %w", err)
|
||||||
@@ -34,6 +35,7 @@ func newTemplateHandler(portalUrl string) (*TemplateHandler, error) {
|
|||||||
|
|
||||||
handler := &TemplateHandler{
|
handler := &TemplateHandler{
|
||||||
portalUrl: portalUrl,
|
portalUrl: portalUrl,
|
||||||
|
portalName: portalName,
|
||||||
htmlTemplates: htmlTemplateCache,
|
htmlTemplates: htmlTemplateCache,
|
||||||
textTemplates: txtTemplateCache,
|
textTemplates: txtTemplateCache,
|
||||||
}
|
}
|
||||||
@@ -81,6 +83,7 @@ func (c TemplateHandler) GetConfigMailWithAttachment(user *domain.User, cfgName,
|
|||||||
"ConfigFileName": cfgName,
|
"ConfigFileName": cfgName,
|
||||||
"QrcodePngName": qrName,
|
"QrcodePngName": qrName,
|
||||||
"PortalUrl": c.portalUrl,
|
"PortalUrl": c.portalUrl,
|
||||||
|
"PortalName": c.portalName,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gotpl: %w", err)
|
return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gotpl: %w", err)
|
||||||
@@ -91,6 +94,7 @@ func (c TemplateHandler) GetConfigMailWithAttachment(user *domain.User, cfgName,
|
|||||||
"ConfigFileName": cfgName,
|
"ConfigFileName": cfgName,
|
||||||
"QrcodePngName": qrName,
|
"QrcodePngName": qrName,
|
||||||
"PortalUrl": c.portalUrl,
|
"PortalUrl": c.portalUrl,
|
||||||
|
"PortalName": c.portalName,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gohtml: %w", err)
|
return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gohtml: %w", err)
|
||||||
|
@@ -19,7 +19,7 @@
|
|||||||
<!--[if !mso]><!-->
|
<!--[if !mso]><!-->
|
||||||
<link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" />
|
||||||
<!--<![endif]-->
|
<!--<![endif]-->
|
||||||
<title>Email Template</title>
|
<title>{{$.PortalName}}</title>
|
||||||
<!--[if gte mso 9]>
|
<!--[if gte mso 9]>
|
||||||
<style type="text/css" media="all">
|
<style type="text/css" media="all">
|
||||||
sup { font-size: 100% !important; }
|
sup { font-size: 100% !important; }
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
<td align="left">
|
<td align="left">
|
||||||
<table border="0" cellspacing="0" cellpadding="0">
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="blue-button text-button" style="background:#000000; color:#c1cddc; font-family:'Muli', Arial,sans-serif; font-size:14px; line-height:18px; padding:12px 30px; text-align:center; border-radius:0px 22px 22px 22px; font-weight:bold;"><a href="https://www.wireguard.com/install" target="_blank" class="link-white" style="color:#ffffff; text-decoration:none;"><span class="link-white" style="color:#ffffff; text-decoration:none;">Download WireGuard VPN Client</span></a></td>
|
<td class="blue-button text-button" style="background:#000000; color:#ffffff; font-family:'Muli', Arial,sans-serif; font-size:14px; line-height:18px; padding:12px 30px; text-align:center; border-radius:0px 22px 22px 22px; font-weight:bold;"><a href="https://www.wireguard.com/install" target="_blank" class="link-white" style="color:#ffffff; text-decoration:none;"><span class="link-white" style="color:#ffffff; text-decoration:none;">Download WireGuard VPN Client</span></a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
@@ -167,10 +167,10 @@
|
|||||||
<td class="p30-15 bbrr" style="padding: 50px 30px; border-radius:0px 0px 26px 26px;" bgcolor="#ffffff">
|
<td class="p30-15 bbrr" style="padding: 50px 30px; border-radius:0px 0px 26px 26px;" bgcolor="#ffffff">
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated using WireGuard Portal.</td>
|
<td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated by {{$.PortalName}}.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{$.PortalUrl}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit WireGuard Portal</span></a></td>
|
<td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{$.PortalUrl}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit {{$.PortalName}}</span></a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
|
@@ -20,5 +20,5 @@ You can download and install the WireGuard VPN client from:
|
|||||||
https://www.wireguard.com/install/
|
https://www.wireguard.com/install/
|
||||||
|
|
||||||
|
|
||||||
This mail was generated using WireGuard Portal.
|
This mail was generated by {{$.PortalName}}.
|
||||||
{{$.PortalUrl}}
|
{{$.PortalUrl}}
|
@@ -19,7 +19,7 @@
|
|||||||
<!--[if !mso]><!-->
|
<!--[if !mso]><!-->
|
||||||
<link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" />
|
||||||
<!--<![endif]-->
|
<!--<![endif]-->
|
||||||
<title>Email Template</title>
|
<title>{{$.PortalName}}</title>
|
||||||
<!--[if gte mso 9]>
|
<!--[if gte mso 9]>
|
||||||
<style type="text/css" media="all">
|
<style type="text/css" media="all">
|
||||||
sup { font-size: 100% !important; }
|
sup { font-size: 100% !important; }
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
<td align="left">
|
<td align="left">
|
||||||
<table border="0" cellspacing="0" cellpadding="0">
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="blue-button text-button" style="background:#000000; color:#c1cddc; font-family:'Muli', Arial,sans-serif; font-size:14px; line-height:18px; padding:12px 30px; text-align:center; border-radius:0px 22px 22px 22px; font-weight:bold;"><a href="https://www.wireguard.com/install" target="_blank" class="link-white" style="color:#ffffff; text-decoration:none;"><span class="link-white" style="color:#ffffff; text-decoration:none;">Download WireGuard VPN Client</span></a></td>
|
<td class="blue-button text-button" style="background:#000000; color:#ffffff; font-family:'Muli', Arial,sans-serif; font-size:14px; line-height:18px; padding:12px 30px; text-align:center; border-radius:0px 22px 22px 22px; font-weight:bold;"><a href="https://www.wireguard.com/install" target="_blank" class="link-white" style="color:#ffffff; text-decoration:none;"><span class="link-white" style="color:#ffffff; text-decoration:none;">Download WireGuard VPN Client</span></a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
@@ -167,10 +167,10 @@
|
|||||||
<td class="p30-15 bbrr" style="padding: 50px 30px; border-radius:0px 0px 26px 26px;" bgcolor="#ffffff">
|
<td class="p30-15 bbrr" style="padding: 50px 30px; border-radius:0px 0px 26px 26px;" bgcolor="#ffffff">
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated using WireGuard Portal.</td>
|
<td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated by {{$.PortalName}}.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{$.PortalUrl}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit WireGuard Portal</span></a></td>
|
<td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{$.PortalUrl}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit {{$.PortalName}}</span></a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
|
@@ -20,5 +20,5 @@ You can download and install the WireGuard VPN client from:
|
|||||||
https://www.wireguard.com/install/
|
https://www.wireguard.com/install/
|
||||||
|
|
||||||
|
|
||||||
This mail was generated using WireGuard Portal.
|
This mail was generated by {{$.PortalName}}.
|
||||||
{{$.PortalUrl}}
|
{{$.PortalUrl}}
|
@@ -25,6 +25,8 @@ type UserDatabaseRepo interface {
|
|||||||
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
||||||
// GetUserByEmail returns the user with the given email address.
|
// GetUserByEmail returns the user with the given email address.
|
||||||
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
|
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
|
||||||
|
// GetUserByWebAuthnCredential returns the user for the given WebAuthn credential ID.
|
||||||
|
GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error)
|
||||||
// GetAllUsers returns all users.
|
// GetAllUsers returns all users.
|
||||||
GetAllUsers(ctx context.Context) ([]domain.User, error)
|
GetAllUsers(ctx context.Context) ([]domain.User, error)
|
||||||
// FindUsers returns all users matching the search string.
|
// FindUsers returns all users matching the search string.
|
||||||
@@ -129,6 +131,25 @@ func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserByWebAuthnCredential returns the user for the given WebAuthn credential.
|
||||||
|
func (m Manager) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) {
|
||||||
|
|
||||||
|
user, err := m.users.GetUserByWebAuthnCredential(ctx, credentialIdBase64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to load user for webauthn credential %s: %w", credentialIdBase64, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := domain.ValidateUserAccessRights(ctx, user.Identifier); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case
|
||||||
|
|
||||||
|
user.LinkedPeerCount = len(peers)
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetAllUsers returns all users.
|
// GetAllUsers returns all users.
|
||||||
func (m Manager) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
func (m Manager) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
||||||
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
||||||
@@ -343,6 +364,10 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Use
|
|||||||
return errors.Join(fmt.Errorf("no access: %w", err), domain.ErrInvalidData)
|
return errors.Join(fmt.Errorf("no access: %w", err), domain.ErrInvalidData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := new.HasWeakPassword(m.cfg.Auth.MinPasswordLength); err != nil {
|
||||||
|
return errors.Join(fmt.Errorf("password too weak: %w", err), domain.ErrInvalidData)
|
||||||
|
}
|
||||||
|
|
||||||
if currentUser.Id == old.Identifier && old.IsAdmin && !new.IsAdmin {
|
if currentUser.Id == old.Identifier && old.IsAdmin && !new.IsAdmin {
|
||||||
return fmt.Errorf("cannot remove own admin rights: %w", domain.ErrInvalidData)
|
return fmt.Errorf("cannot remove own admin rights: %w", domain.ErrInvalidData)
|
||||||
}
|
}
|
||||||
@@ -397,7 +422,11 @@ func (m Manager) validateCreation(ctx context.Context, new *domain.User) error {
|
|||||||
|
|
||||||
// database users must have a password
|
// database users must have a password
|
||||||
if new.Source == domain.UserSourceDatabase && string(new.Password) == "" {
|
if new.Source == domain.UserSourceDatabase && string(new.Password) == "" {
|
||||||
return fmt.Errorf("invalid password: %w", domain.ErrInvalidData)
|
return fmt.Errorf("missing password: %w", domain.ErrInvalidData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := new.HasWeakPassword(m.cfg.Auth.MinPasswordLength); err != nil {
|
||||||
|
return errors.Join(fmt.Errorf("password too weak: %w", err), domain.ErrInvalidData)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/h44z/wg-portal/internal/app"
|
"github.com/h44z/wg-portal/internal/app"
|
||||||
|
"github.com/h44z/wg-portal/internal/app/webhooks/models"
|
||||||
"github.com/h44z/wg-portal/internal/config"
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
"github.com/h44z/wg-portal/internal/domain"
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -64,6 +65,7 @@ func (m Manager) connectToMessageBus() {
|
|||||||
_ = m.bus.Subscribe(app.TopicPeerCreated, m.handlePeerCreateEvent)
|
_ = m.bus.Subscribe(app.TopicPeerCreated, m.handlePeerCreateEvent)
|
||||||
_ = m.bus.Subscribe(app.TopicPeerUpdated, m.handlePeerUpdateEvent)
|
_ = m.bus.Subscribe(app.TopicPeerUpdated, m.handlePeerUpdateEvent)
|
||||||
_ = m.bus.Subscribe(app.TopicPeerDeleted, m.handlePeerDeleteEvent)
|
_ = m.bus.Subscribe(app.TopicPeerDeleted, m.handlePeerDeleteEvent)
|
||||||
|
_ = m.bus.Subscribe(app.TopicPeerStateChanged, m.handlePeerStateChangeEvent)
|
||||||
|
|
||||||
_ = m.bus.Subscribe(app.TopicInterfaceCreated, m.handleInterfaceCreateEvent)
|
_ = m.bus.Subscribe(app.TopicInterfaceCreated, m.handleInterfaceCreateEvent)
|
||||||
_ = m.bus.Subscribe(app.TopicInterfaceUpdated, m.handleInterfaceUpdateEvent)
|
_ = m.bus.Subscribe(app.TopicInterfaceUpdated, m.handleInterfaceUpdateEvent)
|
||||||
@@ -100,39 +102,47 @@ func (m Manager) sendWebhook(ctx context.Context, data io.Reader) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) handleUserCreateEvent(user domain.User) {
|
func (m Manager) handleUserCreateEvent(user domain.User) {
|
||||||
m.handleGenericEvent(WebhookEventCreate, user)
|
m.handleGenericEvent(WebhookEventCreate, models.NewUser(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) handleUserUpdateEvent(user domain.User) {
|
func (m Manager) handleUserUpdateEvent(user domain.User) {
|
||||||
m.handleGenericEvent(WebhookEventUpdate, user)
|
m.handleGenericEvent(WebhookEventUpdate, models.NewUser(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) handleUserDeleteEvent(user domain.User) {
|
func (m Manager) handleUserDeleteEvent(user domain.User) {
|
||||||
m.handleGenericEvent(WebhookEventDelete, user)
|
m.handleGenericEvent(WebhookEventDelete, models.NewUser(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) handlePeerCreateEvent(peer domain.Peer) {
|
func (m Manager) handlePeerCreateEvent(peer domain.Peer) {
|
||||||
m.handleGenericEvent(WebhookEventCreate, peer)
|
m.handleGenericEvent(WebhookEventCreate, models.NewPeer(peer))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) handlePeerUpdateEvent(peer domain.Peer) {
|
func (m Manager) handlePeerUpdateEvent(peer domain.Peer) {
|
||||||
m.handleGenericEvent(WebhookEventUpdate, peer)
|
m.handleGenericEvent(WebhookEventUpdate, models.NewPeer(peer))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) handlePeerDeleteEvent(peer domain.Peer) {
|
func (m Manager) handlePeerDeleteEvent(peer domain.Peer) {
|
||||||
m.handleGenericEvent(WebhookEventDelete, peer)
|
m.handleGenericEvent(WebhookEventDelete, models.NewPeer(peer))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) handleInterfaceCreateEvent(iface domain.Interface) {
|
func (m Manager) handleInterfaceCreateEvent(iface domain.Interface) {
|
||||||
m.handleGenericEvent(WebhookEventCreate, iface)
|
m.handleGenericEvent(WebhookEventCreate, models.NewInterface(iface))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) handleInterfaceUpdateEvent(iface domain.Interface) {
|
func (m Manager) handleInterfaceUpdateEvent(iface domain.Interface) {
|
||||||
m.handleGenericEvent(WebhookEventUpdate, iface)
|
m.handleGenericEvent(WebhookEventUpdate, models.NewInterface(iface))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) handleInterfaceDeleteEvent(iface domain.Interface) {
|
func (m Manager) handleInterfaceDeleteEvent(iface domain.Interface) {
|
||||||
m.handleGenericEvent(WebhookEventDelete, iface)
|
m.handleGenericEvent(WebhookEventDelete, models.NewInterface(iface))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) handlePeerStateChangeEvent(peerStatus domain.PeerStatus, peer domain.Peer) {
|
||||||
|
if peerStatus.IsConnected {
|
||||||
|
m.handleGenericEvent(WebhookEventConnect, models.NewPeerMetrics(peerStatus, peer))
|
||||||
|
} else {
|
||||||
|
m.handleGenericEvent(WebhookEventDisconnect, models.NewPeerMetrics(peerStatus, peer))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Manager) handleGenericEvent(action WebhookEvent, payload any) {
|
func (m Manager) handleGenericEvent(action WebhookEvent, payload any) {
|
||||||
@@ -168,15 +178,18 @@ func (m Manager) createWebhookData(action WebhookEvent, payload any) (*WebhookDa
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch v := payload.(type) {
|
switch v := payload.(type) {
|
||||||
case domain.User:
|
case models.User:
|
||||||
d.Entity = WebhookEntityUser
|
d.Entity = WebhookEntityUser
|
||||||
d.Identifier = string(v.Identifier)
|
d.Identifier = v.Identifier
|
||||||
case domain.Peer:
|
case models.Peer:
|
||||||
d.Entity = WebhookEntityPeer
|
d.Entity = WebhookEntityPeer
|
||||||
d.Identifier = string(v.Identifier)
|
d.Identifier = v.Identifier
|
||||||
case domain.Interface:
|
case models.Interface:
|
||||||
d.Entity = WebhookEntityInterface
|
d.Entity = WebhookEntityInterface
|
||||||
d.Identifier = string(v.Identifier)
|
d.Identifier = v.Identifier
|
||||||
|
case models.PeerMetrics:
|
||||||
|
d.Entity = WebhookEntityPeerMetric
|
||||||
|
d.Identifier = v.Peer.Identifier
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported payload type: %T", v)
|
return nil, fmt.Errorf("unsupported payload type: %T", v)
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user