mirror of
https://github.com/h44z/wg-portal.git
synced 2025-10-05 07:56:17 +00:00
Compare commits
18 Commits
v2.1.0-bet
...
stable
Author | SHA1 | Date | |
---|---|---|---|
|
0cc7ebb83e | ||
|
eb6a787cfc | ||
|
b546eec4ed | ||
|
9be2133220 | ||
|
b05837b2d9 | ||
|
08c8f8eac0 | ||
|
d864e24145 | ||
|
5b56e58fe9 | ||
|
930ef7b573 | ||
|
18296673d7 | ||
|
4ccc59c109 | ||
|
e6b01a9903 | ||
|
2f79dd04c0 | ||
|
e5ed9736b3 | ||
|
c8353b85ae | ||
|
6142031387 | ||
|
dd86d0ff49 | ||
|
bdd426a679 |
5
.github/dependabot.yml
vendored
5
.github/dependabot.yml
vendored
@@ -28,8 +28,3 @@ updates:
|
||||
patch:
|
||||
update-types:
|
||||
- patch
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
4
.github/workflows/chart.yml
vendored
4
.github/workflows/chart.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
|
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download binaries
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: binaries
|
||||
|
||||
|
2
.github/workflows/pages.yml
vendored
2
.github/workflows/pages.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
@@ -20,7 +20,7 @@ RUN npm run build
|
||||
######
|
||||
# Build backend
|
||||
######
|
||||
FROM --platform=${BUILDPLATFORM} golang:1.25-alpine AS builder
|
||||
FROM --platform=${BUILDPLATFORM} golang:1.24-alpine AS builder
|
||||
# Set the working directory
|
||||
WORKDIR /build
|
||||
# Download dependencies
|
||||
@@ -50,7 +50,7 @@ COPY --from=builder /build/dist/wg-portal /
|
||||
######
|
||||
# Final image
|
||||
######
|
||||
FROM alpine:3.22
|
||||
FROM alpine:3.19
|
||||
# Install OS-level dependencies
|
||||
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools
|
||||
# Setup timezone
|
||||
|
@@ -27,12 +27,11 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos
|
||||
* Sends email to the client with QR-code and client config
|
||||
* Enable / Disable clients seamlessly
|
||||
* Generation of wg-quick configuration file (`wgX.conf`) if required
|
||||
* User authentication (database, OAuth, or LDAP), Passkey support
|
||||
* User authentication (database, OAuth, or LDAP)
|
||||
* IPv6 ready
|
||||
* Docker ready
|
||||
* Can be used with existing WireGuard setups
|
||||
* Support for multiple WireGuard interfaces
|
||||
* Supports multiple WireGuard backends (wgctrl or MikroTik [BETA])
|
||||
* Peer Expiry Feature
|
||||
* Handles route and DNS settings like wg-quick does
|
||||
* Exposes Prometheus metrics for monitoring and alerting
|
||||
|
@@ -50,8 +50,7 @@ func main() {
|
||||
database, err := adapters.NewSqlRepository(rawDb)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
wireGuard, err := wireguard.NewControllerManager(cfg)
|
||||
internal.AssertNoError(err)
|
||||
wireGuard := adapters.NewWireGuardRepository()
|
||||
|
||||
wgQuick := adapters.NewWgQuickRepo()
|
||||
|
||||
@@ -88,10 +87,6 @@ func main() {
|
||||
|
||||
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager)
|
||||
internal.AssertNoError(err)
|
||||
authenticator.StartBackgroundJobs(ctx)
|
||||
|
||||
webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database)
|
||||
internal.AssertNoError(err)
|
||||
@@ -129,13 +124,12 @@ func main() {
|
||||
apiV0BackendInterfaces := backendV0.NewInterfaceService(cfg, wireGuardManager, cfgFileManager)
|
||||
apiV0BackendPeers := backendV0.NewPeerService(cfg, wireGuardManager, cfgFileManager, mailManager)
|
||||
|
||||
apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator,
|
||||
webAuthn)
|
||||
apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator)
|
||||
apiV0EndpointAudit := handlersV0.NewAuditEndpoint(cfg, apiV0Auth, auditManager)
|
||||
apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers)
|
||||
apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces)
|
||||
apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers)
|
||||
apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard)
|
||||
apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth)
|
||||
apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth)
|
||||
|
||||
apiFrontend := handlersV0.NewRestApi(apiV0Session,
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 134 KiB |
Binary file not shown.
Before Width: | Height: | Size: 110 KiB |
Binary file not shown.
Before Width: | Height: | Size: 106 KiB |
@@ -32,10 +32,6 @@ database:
|
||||
type: sqlite
|
||||
dsn: data/sqlite.db
|
||||
encryption_passphrase: change-this-s3cr3t-encryption-passphrase
|
||||
|
||||
auth:
|
||||
webauthn:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
## LDAP Authentication and Synchronization
|
||||
|
@@ -14,7 +14,7 @@ Configuration examples are available on the [Examples](./examples.md) page.
|
||||
```yaml
|
||||
core:
|
||||
admin_user: admin@wgportal.local
|
||||
admin_password: wgportal-default
|
||||
admin_password: wgportal
|
||||
admin_api_token: ""
|
||||
editable_keys: true
|
||||
create_default_peer: false
|
||||
@@ -25,9 +25,6 @@ core:
|
||||
import_existing: true
|
||||
restore_state: true
|
||||
|
||||
backend:
|
||||
default: local
|
||||
|
||||
advanced:
|
||||
log_level: info
|
||||
log_pretty: false
|
||||
@@ -41,7 +38,6 @@ advanced:
|
||||
rule_prio_offset: 20000
|
||||
route_table_offset: 20000
|
||||
api_admin_only: true
|
||||
limit_additional_user_peers: 0
|
||||
|
||||
database:
|
||||
debug: false
|
||||
@@ -76,10 +72,6 @@ auth:
|
||||
oidc: []
|
||||
oauth: []
|
||||
ldap: []
|
||||
webauthn:
|
||||
enabled: true
|
||||
min_password_length: 16
|
||||
hide_login_form: false
|
||||
|
||||
web:
|
||||
listening_address: :8888
|
||||
@@ -105,7 +97,6 @@ webhook:
|
||||
|
||||
Below you will find sections like
|
||||
[`core`](#core),
|
||||
[`backend`](#backend),
|
||||
[`advanced`](#advanced),
|
||||
[`database`](#database),
|
||||
[`statistics`](#statistics),
|
||||
@@ -127,9 +118,8 @@ More advanced options are found in the subsequent `Advanced` section.
|
||||
- **Description:** The administrator user. This user will be created as a default admin if it does not yet exist.
|
||||
|
||||
### `admin_password`
|
||||
- **Default:** `wgportal-default`
|
||||
- **Description:** The administrator password. The default password should be changed immediately!
|
||||
- **Important:** The password should be strong and secure. The minimum password length is specified in [auth.min_password_length](#min_password_length). By default, it is 16 characters.
|
||||
- **Default:** `wgportal`
|
||||
- **Description:** The administrator password. The default password of `wgportal` should be changed immediately.
|
||||
|
||||
### `admin_api_token`
|
||||
- **Default:** *(empty)*
|
||||
@@ -169,65 +159,6 @@ 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.
|
||||
|
||||
### 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.
|
||||
|
||||
#### `debug`
|
||||
- **Default:** `false`
|
||||
- **Description:** Enable verbose debug logging for the MikroTik backend.
|
||||
|
||||
For more details on configuring the MikroTik backend, see the [Backends](../usage/backends.md) documentation.
|
||||
|
||||
---
|
||||
|
||||
## Advanced
|
||||
|
||||
Additional or more specialized configuration options for logging and interface creation details.
|
||||
@@ -280,10 +211,6 @@ Additional or more specialized configuration options for logging and interface c
|
||||
- **Default:** `true`
|
||||
- **Description:** If `true`, the public REST API is accessible only to admin users. The API docs live at [`/api/v1/doc.html`](../rest-api/api-doc.md).
|
||||
|
||||
### `limit_additional_user_peers`
|
||||
- **Default:** `0`
|
||||
- **Description:** Limit additional peers a normal user can create. `0` means unlimited.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
@@ -407,23 +334,9 @@ Options for configuring email notifications or sending peer configurations via e
|
||||
|
||||
## Auth
|
||||
|
||||
WireGuard Portal supports multiple authentication strategies, including **OpenID Connect** (`oidc`), **OAuth** (`oauth`), **Passkeys** (`webauthn`) and **LDAP** (`ldap`).
|
||||
WireGuard Portal supports multiple authentication strategies, including **OpenID Connect** (`oidc`), **OAuth** (`oauth`), and **LDAP** (`ldap`).
|
||||
Each can have multiple providers configured. Below are the relevant keys.
|
||||
|
||||
Some core authentication options are shared across all providers, while others are specific to each provider type.
|
||||
|
||||
### `min_password_length`
|
||||
- **Default:** `16`
|
||||
- **Description:** Minimum password length for local authentication. This is not enforced for LDAP authentication.
|
||||
The default admin password strength is also enforced by this setting.
|
||||
- **Important:** The password should be strong and secure. It is recommended to use a password with at least 16 characters, including uppercase and lowercase letters, numbers, and special characters.
|
||||
|
||||
### `hide_login_form`
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, the login form is hidden and only the OIDC, OAuth, LDAP, or WebAuthn providers are shown. This is useful if you want to enforce a specific authentication method.
|
||||
If no social login providers are configured, the login form is always shown, regardless of this setting.
|
||||
- **Important:** You can still access the login form by adding the `?all` query parameter to the login URL (e.g. https://wg.portal/#/login?all).
|
||||
|
||||
---
|
||||
|
||||
### OIDC
|
||||
@@ -627,8 +540,6 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
|
||||
```text
|
||||
(&(objectClass=organizationalPerson)(mail={{login_identifier}})(!userAccountControl:1.2.840.113556.1.4.803:=2))
|
||||
```
|
||||
- **Important**: The `login_filter` must always be a valid LDAP filter. It should at most return one user.
|
||||
If the filter returns multiple or no users, the login will fail.
|
||||
|
||||
#### `admin_group`
|
||||
- **Default:** *(empty)*
|
||||
@@ -669,16 +580,6 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
|
||||
|
||||
---
|
||||
|
||||
### WebAuthn (Passkeys)
|
||||
|
||||
The `webauthn` section contains configuration options for WebAuthn authentication (passkeys).
|
||||
|
||||
#### `enabled`
|
||||
- **Default:** `true`
|
||||
- **Description:** If `true`, Passkey authentication is enabled. If `false`, WebAuthn is disabled.
|
||||
Users are encouraged to use Passkeys for secure authentication instead of passwords.
|
||||
If a passkey is registered, the password login is still available as a fallback. Ensure that the password is strong and secure.
|
||||
|
||||
## Web
|
||||
|
||||
The web section contains configuration options for the web server, including the listening address, session management, and CSRF protection.
|
||||
@@ -736,7 +637,18 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
|
||||
## Webhook
|
||||
|
||||
The webhook section allows you to configure a webhook that is called on certain events in WireGuard Portal.
|
||||
Further details can be found in the [usage documentation](../usage/webhooks.md).
|
||||
A JSON object is sent in a POST request to the webhook URL with the following structure:
|
||||
```json
|
||||
{
|
||||
"event": "peer_created",
|
||||
"entity": "peer",
|
||||
"identifier": "the-peer-identifier",
|
||||
"payload": {
|
||||
// The payload of the event, e.g. peer data.
|
||||
// Check the API documentation for the exact structure.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `url`
|
||||
- **Default:** *(empty)*
|
||||
|
@@ -403,12 +403,6 @@ definitions:
|
||||
type: object
|
||||
models.ProvisioningRequest:
|
||||
properties:
|
||||
DisplayName:
|
||||
description: |-
|
||||
DisplayName is an optional name for the new peer.
|
||||
If unset, a default template value (e.g., "API Peer ...") will be assigned.
|
||||
example: API Peer xyz
|
||||
type: string
|
||||
InterfaceIdentifier:
|
||||
description: InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to.
|
||||
example: wg0
|
||||
|
@@ -1,57 +0,0 @@
|
||||
# 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)
|
@@ -1,57 +0,0 @@
|
||||
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.
|
@@ -1,37 +0,0 @@
|
||||
WireGuard Portal lets you hook up any LDAP server such as Active Directory or OpenLDAP for both authentication and user sync.
|
||||
You can even register multiple LDAP servers side-by-side. When someone logs in via LDAP, their specific provider is remembered,
|
||||
so there's no risk of cross-provider conflicts. Details on the log-in process can be found in the [Security](security.md#ldap-authentication) documentation.
|
||||
|
||||
If you enable LDAP synchronization, all users within the LDAP directory will be created automatically in the WireGuard Portal database if they do not exist.
|
||||
If a user is disabled or deleted in LDAP, the user will be disabled in WireGuard Portal as well.
|
||||
The synchronization process can be fine-tuned by multiple parameters, which are described below.
|
||||
|
||||
## LDAP Synchronization
|
||||
|
||||
WireGuard Portal can automatically synchronize users from LDAP to the database.
|
||||
To enable this feature, set the `sync_interval` property in the LDAP provider configuration to a value greater than "0".
|
||||
The value is a string representing a duration, such as "15m" for 15 minutes or "1h" for 1 hour (check the [exact format definition](https://pkg.go.dev/time#ParseDuration) for details).
|
||||
The synchronization process will run in the background and synchronize users from LDAP to the database at the specified interval.
|
||||
Also make sure that the `sync_filter` property is a well-formed LDAP filter, or synchronization will fail.
|
||||
|
||||
### Limiting Synchronization to Specific Users
|
||||
|
||||
Use the `sync_filter` property in your LDAP provider block to restrict which users get synchronized.
|
||||
It accepts any valid LDAP search filter, only entries matching that filter will be pulled into the portal's database.
|
||||
|
||||
For example, to import only users with a `mail` attribute:
|
||||
```yaml
|
||||
auth:
|
||||
ldap:
|
||||
- id: ldap
|
||||
# ... other settings
|
||||
sync_filter: (mail=*)
|
||||
```
|
||||
|
||||
### Disable Missing Users
|
||||
|
||||
If you set the `disable_missing` property to `true`, any user that is not found in LDAP during synchronization will be disabled in WireGuard Portal.
|
||||
All peers associated with that user will also be disabled.
|
||||
|
||||
If you want a user and its peers to be automatically re-enabled once they are found in LDAP again, set the `auto_re_enable` property to `true`.
|
||||
This will only re-enable the user if they where disabled by the synchronization process. Manually disabled users will not be re-enabled.
|
@@ -1,160 +0,0 @@
|
||||
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.
|
@@ -1,285 +0,0 @@
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
```
|
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link href="/favicon.ico" rel="icon" />
|
||||
|
23
frontend/package-lock.json
generated
23
frontend/package-lock.json
generated
@@ -12,10 +12,9 @@
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@kyvg/vue3-notification": "^3.4.1",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@vojtechlanka/vue-tags-input": "^3.1.1",
|
||||
"bootstrap": "^5.3.7",
|
||||
"bootswatch": "^5.3.7",
|
||||
"bootstrap": "^5.3.5",
|
||||
"bootswatch": "^5.3.5",
|
||||
"flag-icons": "^7.3.2",
|
||||
"ip-address": "^10.0.1",
|
||||
"is-cidr": "^5.1.1",
|
||||
@@ -864,12 +863,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "13.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.1.0.tgz",
|
||||
"integrity": "sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||
@@ -1048,9 +1041,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz",
|
||||
"integrity": "sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==",
|
||||
"version": "5.3.5",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.5.tgz",
|
||||
"integrity": "sha512-ct1CHKtiobRimyGzmsSldEtM03E8fcEX4Tb3dGXz1V8faRwM50+vfHwTzOxB3IlKO7m+9vTH3s/3C6T2EAPeTA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1067,9 +1060,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bootswatch": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/bootswatch/-/bootswatch-5.3.7.tgz",
|
||||
"integrity": "sha512-n0X99+Jmpmd4vgkli5KwMOuAkgdyUPhq7cIAwoGXbM6WhE/mmkWACfxpr7WZeG9Pdx509Ndi+2K1HlzXXOr8/Q==",
|
||||
"version": "5.3.5",
|
||||
"resolved": "https://registry.npmjs.org/bootswatch/-/bootswatch-5.3.5.tgz",
|
||||
"integrity": "sha512-1z8LNoUL5NHmv/hNROALQ6qtjw9OJIjMgP8ovBlIft+oI15b/mvnzxGL896iO9LtoDZH0Vdm+D2YW+j03GduSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer-builder": {
|
||||
|
@@ -12,10 +12,9 @@
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@kyvg/vue3-notification": "^3.4.1",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@vojtechlanka/vue-tags-input": "^3.1.1",
|
||||
"bootstrap": "^5.3.7",
|
||||
"bootswatch": "^5.3.7",
|
||||
"bootstrap": "^5.3.5",
|
||||
"bootswatch": "^5.3.5",
|
||||
"flag-icons": "^7.3.2",
|
||||
"ip-address": "^10.0.1",
|
||||
"is-cidr": "^5.1.1",
|
||||
|
@@ -14,10 +14,6 @@ const settings = settingsStore()
|
||||
onMounted(async () => {
|
||||
console.log("Starting WireGuard Portal frontend...");
|
||||
|
||||
// restore theme from localStorage
|
||||
const theme = localStorage.getItem('wgTheme') || 'light';
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
|
||||
await sec.LoadSecurityProperties();
|
||||
await auth.LoadProviders();
|
||||
|
||||
@@ -44,13 +40,6 @@ const switchLanguage = function (lang) {
|
||||
}
|
||||
}
|
||||
|
||||
const switchTheme = function (theme) {
|
||||
if (document.documentElement.getAttribute('data-bs-theme') !== theme) {
|
||||
localStorage.setItem('wgTheme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
}
|
||||
}
|
||||
|
||||
const languageFlag = computed(() => {
|
||||
// `this` points to the component instance
|
||||
let lang = appGlobal.$i18n.locale.toLowerCase();
|
||||
@@ -127,7 +116,7 @@ const userDisplayName = computed(() => {
|
||||
href="#" role="button">{{ userDisplayName }}</a>
|
||||
<div class="dropdown-menu">
|
||||
<RouterLink :to="{ name: 'profile' }" class="dropdown-item"><i class="fas fa-user"></i> {{ $t('menu.profile') }}</RouterLink>
|
||||
<RouterLink :to="{ name: 'settings' }" class="dropdown-item" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly') || settings.Setting('WebAuthnEnabled')"><i class="fas fa-gears"></i> {{ $t('menu.settings') }}</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: 'audit' }" class="dropdown-item" v-if="auth.IsAdmin"><i class="fas fa-file-shield"></i> {{ $t('menu.audit') }}</RouterLink>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="#" @click.prevent="auth.Logout"><i class="fas fa-sign-out-alt"></i> {{ $t('menu.logout') }}</a>
|
||||
@@ -136,24 +125,6 @@ const userDisplayName = computed(() => {
|
||||
<div v-if="!auth.IsAuthenticated" class="nav-item">
|
||||
<RouterLink :to="{ name: 'login' }" class="nav-link"><i class="fas fa-sign-in-alt fa-sm fa-fw me-2"></i>{{ $t('menu.login') }}</RouterLink>
|
||||
</div>
|
||||
<div class="nav-item dropdown" 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>
|
||||
@@ -170,7 +141,7 @@ const userDisplayName = computed(() => {
|
||||
<div class="col-6 text-end">
|
||||
<div :aria-label="$t('menu.lang')" class="btn-group" role="group">
|
||||
<div class="btn-group" role="group">
|
||||
<button aria-expanded="false" aria-haspopup="true" class="btn flag-button pe-0"
|
||||
<button aria-expanded="false" aria-haspopup="true" class="btn btn btn-secondary pe-0"
|
||||
data-bs-toggle="dropdown" type="button"><span :class="languageFlag" class="fi"></span></button>
|
||||
<div aria-labelledby="btnGroupDrop3" class="dropdown-menu" style="">
|
||||
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('de')"><span class="fi fi-de"></span> Deutsch</a>
|
||||
@@ -192,31 +163,4 @@ const userDisplayName = computed(() => {
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<style></style>
|
||||
|
@@ -65,14 +65,6 @@ a.disabled {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] .vue-tags-input .ti-tag {
|
||||
position: relative;
|
||||
background: #3c3c3c;
|
||||
border: 2px solid var(--bs-body-color);
|
||||
margin: 6px;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
/* the styles if a tag is invalid */
|
||||
.vue-tags-input .ti-tag.ti-invalid {
|
||||
background-color: #e88a74;
|
||||
|
@@ -10,13 +10,11 @@ import isCidr from "is-cidr";
|
||||
import {isIP} from 'is-ip';
|
||||
import { freshInterface } from '@/helpers/models';
|
||||
import {peerStore} from "@/stores/peers";
|
||||
import {settingsStore} from "@/stores/settings";
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const interfaces = interfaceStore()
|
||||
const peers = peerStore()
|
||||
const settings = settingsStore()
|
||||
|
||||
const props = defineProps({
|
||||
interfaceId: String,
|
||||
@@ -50,26 +48,6 @@ const currentTags = ref({
|
||||
PeerDefDnsSearch: ""
|
||||
})
|
||||
const formData = ref(freshInterface())
|
||||
const isSaving = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
const isApplyingDefaults = ref(false)
|
||||
|
||||
const isBackendValid = computed(() => {
|
||||
if (!props.visible || !selectedInterface.value) {
|
||||
return true // if modal is not visible or no interface is selected, we don't care about backend validity
|
||||
}
|
||||
|
||||
let backendId = selectedInterface.value.Backend
|
||||
|
||||
let valid = false
|
||||
let availableBackends = settings.Setting('AvailableBackends') || []
|
||||
availableBackends.forEach(backend => {
|
||||
if (backend.Id === backendId) {
|
||||
valid = true
|
||||
}
|
||||
})
|
||||
return valid
|
||||
})
|
||||
|
||||
// functions
|
||||
|
||||
@@ -83,7 +61,6 @@ watch(() => props.visible, async (newValue, oldValue) => {
|
||||
formData.value.Identifier = interfaces.Prepared.Identifier
|
||||
formData.value.DisplayName = interfaces.Prepared.DisplayName
|
||||
formData.value.Mode = interfaces.Prepared.Mode
|
||||
formData.value.Backend = interfaces.Prepared.Backend
|
||||
|
||||
formData.value.PublicKey = interfaces.Prepared.PublicKey
|
||||
formData.value.PrivateKey = interfaces.Prepared.PrivateKey
|
||||
@@ -122,7 +99,6 @@ watch(() => props.visible, async (newValue, oldValue) => {
|
||||
formData.value.Identifier = selectedInterface.value.Identifier
|
||||
formData.value.DisplayName = selectedInterface.value.DisplayName
|
||||
formData.value.Mode = selectedInterface.value.Mode
|
||||
formData.value.Backend = selectedInterface.value.Backend
|
||||
|
||||
formData.value.PublicKey = selectedInterface.value.PublicKey
|
||||
formData.value.PrivateKey = selectedInterface.value.PrivateKey
|
||||
@@ -261,8 +237,6 @@ function handleChangePeerDefDnsSearch(tags) {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (props.interfaceId!=='#NEW#') {
|
||||
await interfaces.UpdateInterface(selectedInterface.value.Identifier, formData.value)
|
||||
@@ -277,8 +251,6 @@ async function save() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,8 +259,6 @@ async function applyPeerDefaults() {
|
||||
return; // do nothing for new interfaces
|
||||
}
|
||||
|
||||
if (isApplyingDefaults.value) return
|
||||
isApplyingDefaults.value = true
|
||||
try {
|
||||
await interfaces.ApplyPeerDefaults(selectedInterface.value.Identifier, formData.value)
|
||||
|
||||
@@ -306,14 +276,10 @@ async function applyPeerDefaults() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isApplyingDefaults.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function del() {
|
||||
if (isDeleting.value) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await interfaces.DeleteInterface(selectedInterface.value.Identifier)
|
||||
close()
|
||||
@@ -324,8 +290,6 @@ async function del() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,8 +314,7 @@ async function del() {
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.identifier.label') }}</label>
|
||||
<input v-model="formData.Identifier" class="form-control" :placeholder="$t('modals.interface-edit.identifier.placeholder')" type="text">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="form-group col-md-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.mode.label') }}</label>
|
||||
<select v-model="formData.Mode" class="form-select">
|
||||
<option value="server">{{ $t('modals.interface-edit.mode.server') }}</option>
|
||||
@@ -359,14 +322,6 @@ async function del() {
|
||||
<option value="any">{{ $t('modals.interface-edit.mode.any') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label class="form-label mt-4" for="ifaceBackendSelector">{{ $t('modals.interface-edit.backend.label') }}</label>
|
||||
<select id="ifaceBackendSelector" v-model="formData.Backend" class="form-select" aria-describedby="backendHelp">
|
||||
<option v-for="backend in settings.Setting('AvailableBackends')" :value="backend.Id">{{ backend.Id === 'local' ? $t(backend.Name) : backend.Name }}</option>
|
||||
</select>
|
||||
<small v-if="!isBackendValid" id="backendHelp" class="form-text text-warning">{{ $t('modals.interface-edit.backend.invalid-label') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.display-name.label') }}</label>
|
||||
<input v-model="formData.DisplayName" class="form-control" :placeholder="$t('modals.interface-edit.display-name.placeholder')" type="text">
|
||||
@@ -430,14 +385,12 @@ async function del() {
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.mtu.label') }}</label>
|
||||
<input v-model="formData.Mtu" class="form-control" :placeholder="$t('modals.interface-edit.mtu.placeholder')" type="number">
|
||||
</div>
|
||||
<div class="form-group col-md-6" v-if="formData.Backend==='local'">
|
||||
<div class="form-group col-md-6">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.firewall-mark.label') }}</label>
|
||||
<input v-model="formData.FirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number">
|
||||
</div>
|
||||
<div class="form-group col-md-6" v-else>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" v-if="formData.Backend==='local'">
|
||||
<div class="row">
|
||||
<div class="form-group col-md-6">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.routing-table.label') }}</label>
|
||||
<input v-model="formData.RoutingTable" aria-describedby="routingTableHelp" class="form-control" :placeholder="$t('modals.interface-edit.routing-table.placeholder')" type="text">
|
||||
@@ -577,25 +530,16 @@ async function del() {
|
||||
</fieldset>
|
||||
<fieldset v-if="props.interfaceId!=='#NEW#'" class="text-end">
|
||||
<hr class="mt-4">
|
||||
<button class="btn btn-primary me-1" type="button" @click.prevent="applyPeerDefaults" :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>
|
||||
<button class="btn btn-primary me-1" type="button" @click.prevent="applyPeerDefaults">{{ $t('modals.interface-edit.button-apply-defaults') }}</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex-fill text-start">
|
||||
<button v-if="props.interfaceId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del" :disabled="isDeleting">
|
||||
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
{{ $t('general.delete') }}
|
||||
</button>
|
||||
<button v-if="props.interfaceId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
|
||||
</div>
|
||||
<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-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
|
||||
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
@@ -73,8 +73,6 @@ const currentTags = ref({
|
||||
DnsSearch: ""
|
||||
})
|
||||
const formData = ref(freshPeer())
|
||||
const isSaving = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
// functions
|
||||
|
||||
@@ -272,8 +270,6 @@ function handleChangeDnsSearch(tags) {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (props.peerId !== '#NEW#') {
|
||||
await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value)
|
||||
@@ -282,30 +278,26 @@ async function save() {
|
||||
}
|
||||
close()
|
||||
} catch (e) {
|
||||
// console.log(e)
|
||||
notify({
|
||||
title: "Failed to save peer!",
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function del() {
|
||||
if (isDeleting.value) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await peers.DeletePeer(selectedPeer.value.Identifier)
|
||||
close()
|
||||
} catch (e) {
|
||||
// console.log(e)
|
||||
notify({
|
||||
title: "Failed to delete peer!",
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,15 +470,10 @@ async function del() {
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex-fill text-start">
|
||||
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del" :disabled="isDeleting">
|
||||
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
{{ $t('general.delete') }}
|
||||
</button>
|
||||
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{
|
||||
$t('general.delete') }}</button>
|
||||
</div>
|
||||
<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-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
|
||||
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
@@ -38,7 +38,6 @@ function freshForm() {
|
||||
|
||||
const currentTag = ref("")
|
||||
const formData = ref(freshForm())
|
||||
const isSaving = ref(false)
|
||||
|
||||
const title = computed(() => {
|
||||
if (!props.visible) {
|
||||
@@ -61,15 +60,12 @@ function handleChangeUserIdentifiers(tags) {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return
|
||||
isSaving.value = true
|
||||
if (formData.value.Identifiers.length === 0) {
|
||||
notify({
|
||||
title: "Missing Identifiers",
|
||||
text: "At least one identifier is required to create a new peer.",
|
||||
type: 'error',
|
||||
})
|
||||
isSaving.value = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,8 +79,6 @@ async function save() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,10 +108,7 @@ async function save() {
|
||||
</fieldset>
|
||||
</template>
|
||||
<template #footer>
|
||||
<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-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
|
||||
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
@@ -50,7 +50,7 @@ const selectedStats = computed(() => {
|
||||
|
||||
if (!s) {
|
||||
if (!!props.peerId || props.peerId.length) {
|
||||
s = profile.Statistics(props.peerId)
|
||||
p = profile.Statistics(props.peerId)
|
||||
} else {
|
||||
s = freshStats() // dummy stats to avoid 'undefined' exceptions
|
||||
}
|
||||
@@ -79,19 +79,13 @@ const title = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const configStyle = ref("wgquick")
|
||||
|
||||
watch(() => props.visible, async (newValue, oldValue) => {
|
||||
if (oldValue === false && newValue === true) { // if modal is shown
|
||||
await peers.LoadPeerConfig(selectedPeer.value.Identifier, configStyle.value)
|
||||
await peers.LoadPeerConfig(selectedPeer.value.Identifier)
|
||||
configString.value = peers.configuration
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => configStyle.value, async () => {
|
||||
await peers.LoadPeerConfig(selectedPeer.value.Identifier, configStyle.value)
|
||||
configString.value = peers.configuration
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
function download() {
|
||||
// credit: https://www.bitdegree.org/learn/javascript-download
|
||||
@@ -109,7 +103,7 @@ function download() {
|
||||
}
|
||||
|
||||
function email() {
|
||||
peers.MailPeerConfig(settings.Setting("MailLinkOnly"), configStyle.value, [selectedPeer.value.Identifier]).catch(e => {
|
||||
peers.MailPeerConfig(settings.Setting("MailLinkOnly"), [selectedPeer.value.Identifier]).catch(e => {
|
||||
notify({
|
||||
title: "Failed to send mail with peer configuration!",
|
||||
text: e.toString(),
|
||||
@@ -120,7 +114,7 @@ function email() {
|
||||
|
||||
function ConfigQrUrl() {
|
||||
if (props.peerId.length) {
|
||||
return apiWrapper.url(`/peer/config-qr/${base64_url_encode(props.peerId)}?style=${configStyle.value}`)
|
||||
return apiWrapper.url(`/peer/config-qr/${base64_url_encode(props.peerId)}`)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -130,15 +124,6 @@ function ConfigQrUrl() {
|
||||
<template>
|
||||
<Modal :title="title" :visible="visible" @close="close">
|
||||
<template #default>
|
||||
<div class="d-flex justify-content-end align-items-center mb-1">
|
||||
<span class="me-2">{{ $t('modals.peer-view.style-label') }}: </span>
|
||||
<div class="btn-group btn-switch-group" role="group" aria-label="Configuration Style">
|
||||
<input type="radio" class="btn-check" name="configstyle" id="raw" value="raw" autocomplete="off" checked="" v-model="configStyle">
|
||||
<label class="btn btn-outline-dark btn-sm" for="raw">Raw</label>
|
||||
<input type="radio" class="btn-check" name="configstyle" id="wgquick" value="wgquick" autocomplete="off" checked="" v-model="configStyle">
|
||||
<label class="btn btn-outline-dark btn-sm" for="wgquick">WG-Quick</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion" id="peerInformation">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
@@ -228,14 +213,6 @@ function ConfigQrUrl() {
|
||||
</template>
|
||||
</Modal></template>
|
||||
|
||||
<style>
|
||||
.config-qr-img {
|
||||
<style>.config-qr-img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.btn-switch-group .btn {
|
||||
border-width: 1px;
|
||||
padding: 5px;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
}</style>
|
||||
|
@@ -5,12 +5,10 @@ import {computed, ref, watch} from "vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
import {freshUser} from "@/helpers/models";
|
||||
import {settingsStore} from "@/stores/settings";
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const users = userStore()
|
||||
const settings = settingsStore()
|
||||
|
||||
const props = defineProps({
|
||||
userId: String,
|
||||
@@ -34,32 +32,6 @@ const title = computed(() => {
|
||||
})
|
||||
|
||||
const formData = ref(freshUser())
|
||||
const isSaving = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
const passwordWeak = computed(() => {
|
||||
return formData.value.Password && formData.value.Password.length > 0 && formData.value.Password.length < settings.Setting('MinPasswordLength')
|
||||
})
|
||||
|
||||
const formValid = computed(() => {
|
||||
if (formData.value.Source !== 'db') {
|
||||
return true // nothing to validate
|
||||
}
|
||||
if (props.userId !== '#NEW#' && passwordWeak.value) {
|
||||
return false
|
||||
}
|
||||
if (props.userId === '#NEW#' && (!formData.value.Password || formData.value.Password.length < 1)) {
|
||||
return false
|
||||
}
|
||||
if (props.userId === '#NEW#' && passwordWeak.value) {
|
||||
return false
|
||||
}
|
||||
if (!formData.value.Identifier || formData.value.Identifier.length < 1) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
// functions
|
||||
|
||||
@@ -91,8 +63,6 @@ function close() {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (props.userId!=='#NEW#') {
|
||||
await users.UpdateUser(selectedUser.value.Identifier, formData.value)
|
||||
@@ -106,14 +76,10 @@ async function save() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function del() {
|
||||
if (isDeleting.value) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await users.DeleteUser(selectedUser.value.Identifier)
|
||||
close()
|
||||
@@ -123,8 +89,6 @@ async function del() {
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,8 +109,7 @@ async function del() {
|
||||
</div>
|
||||
<div v-if="formData.Source==='db'" class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label>
|
||||
<input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :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>
|
||||
<input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :placeholder="$t('modals.user-edit.password.placeholder')" type="text">
|
||||
<small v-if="props.userId!=='#NEW#'" id="passwordHelp" class="form-text text-muted">{{ $t('modals.user-edit.password.description') }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -203,15 +166,9 @@ async function del() {
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex-fill text-start">
|
||||
<button v-if="props.userId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del" :disabled="isDeleting">
|
||||
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
{{ $t('general.delete') }}
|
||||
</button>
|
||||
<button v-if="props.userId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
|
||||
</div>
|
||||
<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-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
|
||||
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
@@ -55,8 +55,6 @@ const title = computed(() => {
|
||||
})
|
||||
|
||||
const formData = ref(freshPeer())
|
||||
const isSaving = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
// functions
|
||||
|
||||
@@ -165,8 +163,6 @@ function close() {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (props.peerId !== '#NEW#') {
|
||||
await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value)
|
||||
@@ -175,30 +171,26 @@ async function save() {
|
||||
}
|
||||
close()
|
||||
} catch (e) {
|
||||
// console.log(e)
|
||||
notify({
|
||||
title: "Failed to save peer!",
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function del() {
|
||||
if (isDeleting.value) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await peers.DeletePeer(selectedPeer.value.Identifier)
|
||||
close()
|
||||
} catch (e) {
|
||||
// console.log(e)
|
||||
notify({
|
||||
title: "Failed to delete peer!",
|
||||
text: e.toString(),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,15 +283,10 @@ async function del() {
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex-fill text-start">
|
||||
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del" :disabled="isDeleting">
|
||||
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
{{ $t('general.delete') }}
|
||||
</button>
|
||||
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{
|
||||
$t('general.delete') }}</button>
|
||||
</div>
|
||||
<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-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
|
||||
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
@@ -5,7 +5,6 @@ export function freshInterface() {
|
||||
DisplayName: "",
|
||||
Identifier: "",
|
||||
Mode: "server",
|
||||
Backend: "local",
|
||||
|
||||
PublicKey: "",
|
||||
PrivateKey: "",
|
||||
|
@@ -29,8 +29,7 @@
|
||||
"label": "Passwort",
|
||||
"placeholder": "Bitte geben Sie Ihr Passwort ein"
|
||||
},
|
||||
"button": "Anmelden",
|
||||
"button-webauthn": "Passkey verwenden"
|
||||
"button": "Anmelden"
|
||||
},
|
||||
"menu": {
|
||||
"home": "Home",
|
||||
@@ -102,9 +101,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Schnittstellenstatus für",
|
||||
"backend": "Backend",
|
||||
"unknown-backend": "Unbekannt",
|
||||
"wrong-backend": "Ungültiges Backend, das lokale WireGuard Backend wird stattdessen verwendet!",
|
||||
"mode": "Modus",
|
||||
"key": "Öffentlicher Schlüssel",
|
||||
"endpoint": "Öffentlicher Endpunkt",
|
||||
"port": "Port",
|
||||
@@ -191,35 +188,6 @@
|
||||
"button-enable-title": "Aktivieren Sie die API, dadurch wird ein neuer Token generiert.",
|
||||
"button-enable-text": "API aktivieren",
|
||||
"api-link": "API Dokumentation"
|
||||
},
|
||||
"webauthn": {
|
||||
"headline": "Passkey-Einstellungen",
|
||||
"abstract": "Passkeys sind eine moderne Möglichkeit, Benutzer ohne Passwort zu authentifizieren. Sie werden sicher in Ihrem Browser gespeichert und können verwendet werden, um sich im WireGuard-Portal anzumelden.",
|
||||
"active-description": "Mindestens ein Passkey ist derzeit für Ihr Benutzerkonto aktiv.",
|
||||
"inactive-description": "Für Ihr Benutzerkonto sind derzeit keine Passkeys registriert. Drücken Sie die Schaltfläche unten, um einen neuen Passkey zu registrieren.",
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"created": "Erstellt",
|
||||
"actions": ""
|
||||
},
|
||||
"credentials-list": "Derzeit registrierte Passkeys",
|
||||
"modal-delete": {
|
||||
"headline": "Passkey löschen",
|
||||
"abstract": "Sind Sie sicher, dass Sie diesen Passkey löschen möchten? Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.",
|
||||
"created": "Erstellt:",
|
||||
"button-delete": "Löschen",
|
||||
"button-cancel": "Abbrechen"
|
||||
},
|
||||
"button-rename-title": "Umbenennen",
|
||||
"button-rename-text": "Passkey umbenennen.",
|
||||
"button-save-title": "Speichern",
|
||||
"button-save-text": "Neuen Namen des Passkeys speichern.",
|
||||
"button-cancel-title": "Abbrechen",
|
||||
"button-cancel-text": "Umbenennung des Passkeys abbrechen.",
|
||||
"button-delete-title": "Löschen",
|
||||
"button-delete-text": "Passkey löschen. Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.",
|
||||
"button-register-title": "Passkey registrieren",
|
||||
"button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern."
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
@@ -298,8 +266,7 @@
|
||||
"password": {
|
||||
"label": "Passwort",
|
||||
"placeholder": "Ein super geheimes Passwort",
|
||||
"description": "Lassen Sie dieses Feld leer, um das aktuelle Passwort beizubehalten.",
|
||||
"too-weak": "Das Passwort entspricht nicht den Sicherheitsanforderungen."
|
||||
"description": "Lassen Sie dieses Feld leer, um das aktuelle Passwort beizubehalten."
|
||||
},
|
||||
"email": {
|
||||
"label": "E-Mail",
|
||||
@@ -359,11 +326,6 @@
|
||||
"client": "Client-Modus",
|
||||
"any": "Unbekannter Modus"
|
||||
},
|
||||
"backend": {
|
||||
"label": "Schnittstellenbackend",
|
||||
"invalid-label": "Ursprüngliches Backend ist ungültig, das lokale WireGuard Backend wird stattdessen verwendet!",
|
||||
"local": "Lokales WireGuard Backend"
|
||||
},
|
||||
"display-name": {
|
||||
"label": "Anzeigename",
|
||||
"placeholder": "Der beschreibende Name für die Schnittstelle"
|
||||
@@ -474,8 +436,7 @@
|
||||
"connected-since": "Verbunden seit",
|
||||
"endpoint": "Endpunkt",
|
||||
"button-download": "Konfiguration herunterladen",
|
||||
"button-email": "Konfiguration per E-Mail senden",
|
||||
"style-label": "Konfigurationsformat"
|
||||
"button-email": "Konfiguration per E-Mail senden"
|
||||
},
|
||||
"peer-edit": {
|
||||
"headline-edit-peer": "Peer bearbeiten:",
|
||||
|
@@ -29,8 +29,7 @@
|
||||
"label": "Password",
|
||||
"placeholder": "Please enter your password"
|
||||
},
|
||||
"button": "Sign in",
|
||||
"button-webauthn": "Use Passkey"
|
||||
"button": "Sign in"
|
||||
},
|
||||
"menu": {
|
||||
"home": "Home",
|
||||
@@ -102,9 +101,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Interface status for",
|
||||
"backend": "Backend",
|
||||
"unknown-backend": "Unknown",
|
||||
"wrong-backend": "Invalid backend, using local WireGuard backend instead!",
|
||||
"mode": "mode",
|
||||
"key": "Public Key",
|
||||
"endpoint": "Public Endpoint",
|
||||
"port": "Listening Port",
|
||||
@@ -191,35 +188,6 @@
|
||||
"button-enable-title": "Enable API, this will generate a new token.",
|
||||
"button-enable-text": "Enable API",
|
||||
"api-link": "API Documentation"
|
||||
},
|
||||
"webauthn": {
|
||||
"headline": "Passkey Settings",
|
||||
"abstract": "Passkeys are a modern way to authenticate users without the need for passwords. They are stored securely in your browser and can be used to log in to the WireGuard Portal.",
|
||||
"active-description": "At least one passkey is currently active for your user account.",
|
||||
"inactive-description": "No passkeys are currently registered for your user account. Press the button below to register a new passkey.",
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"created": "Created",
|
||||
"actions": ""
|
||||
},
|
||||
"credentials-list": "Currently registered Passkeys",
|
||||
"modal-delete": {
|
||||
"headline": "Delete Passkey",
|
||||
"abstract": "Are you sure you want to delete this passkey? You will not be able to log in with this passkey anymore.",
|
||||
"created": "Created:",
|
||||
"button-delete": "Delete",
|
||||
"button-cancel": "Cancel"
|
||||
},
|
||||
"button-rename-title": "Rename",
|
||||
"button-rename-text": "Rename the passkey.",
|
||||
"button-save-title": "Save",
|
||||
"button-save-text": "Save the new name of the passkey.",
|
||||
"button-cancel-title": "Cancel",
|
||||
"button-cancel-text": "Cancel the renaming of the passkey.",
|
||||
"button-delete-title": "Delete",
|
||||
"button-delete-text": "Delete the passkey. You will not be able to log in with this passkey anymore.",
|
||||
"button-register-title": "Register Passkey",
|
||||
"button-register-text": "Register a new Passkey to secure your account."
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
@@ -298,8 +266,7 @@
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"placeholder": "A super secret password",
|
||||
"description": "Leave this field blank to keep current password.",
|
||||
"too-weak": "The password is too weak. Please use a stronger password."
|
||||
"description": "Leave this field blank to keep current password."
|
||||
},
|
||||
"email": {
|
||||
"label": "Email",
|
||||
@@ -359,11 +326,6 @@
|
||||
"client": "Client Mode",
|
||||
"any": "Unknown Mode"
|
||||
},
|
||||
"backend": {
|
||||
"label": "Interface Backend",
|
||||
"invalid-label": "Original backend is no longer available, using local WireGuard backend instead!",
|
||||
"local": "Local WireGuard Backend"
|
||||
},
|
||||
"display-name": {
|
||||
"label": "Display Name",
|
||||
"placeholder": "The descriptive name for the interface"
|
||||
@@ -475,8 +437,7 @@
|
||||
"connected-since": "Connected since",
|
||||
"endpoint": "Endpoint",
|
||||
"button-download": "Download configuration",
|
||||
"button-email": "Send configuration via E-Mail",
|
||||
"style-label": "Configuration Style"
|
||||
"button-email": "Send configuration via E-Mail"
|
||||
},
|
||||
"peer-edit": {
|
||||
"headline-edit-peer": "Edit peer:",
|
||||
|
@@ -99,7 +99,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "État de l'interface pour",
|
||||
"backend": "backend",
|
||||
"mode": "mode",
|
||||
"key": "Clé publique",
|
||||
"endpoint": "Point de terminaison public",
|
||||
"port": "Port d'écoute",
|
||||
|
@@ -100,7 +100,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "인터페이스 상태:",
|
||||
"backend": "백엔드",
|
||||
"mode": "모드",
|
||||
"key": "공개 키",
|
||||
"endpoint": "공개 엔드포인트",
|
||||
"port": "수신 포트",
|
||||
|
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Status da interface para",
|
||||
"mode": "backend",
|
||||
"mode": "modo",
|
||||
"key": "Chave Pública",
|
||||
"endpoint": "Endpoint Público",
|
||||
"port": "Porta de Escuta",
|
||||
|
@@ -99,7 +99,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Статус интерфейса для",
|
||||
"backend": "бэкэнд",
|
||||
"mode": "режим",
|
||||
"key": "Публичный ключ",
|
||||
"endpoint": "Публичная конечная точка",
|
||||
"port": "Порт прослушивания",
|
||||
|
@@ -99,7 +99,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Статус інтерфейсу для",
|
||||
"backend": "бекенд",
|
||||
"mode": "режим",
|
||||
"key": "Публічний ключ",
|
||||
"endpoint": "Публічна кінцева точка",
|
||||
"port": "Порт прослуховування",
|
||||
|
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "Trạng thái giao diện cho",
|
||||
"backend": "phần sau",
|
||||
"mode": "chế độ",
|
||||
"key": "Khóa Công khai",
|
||||
"endpoint": "Điểm cuối Công khai",
|
||||
"port": "Cổng Nghe",
|
||||
|
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"interface": {
|
||||
"headline": "接口状态",
|
||||
"backend": "后端",
|
||||
"mode": "模式",
|
||||
"key": "公钥",
|
||||
"endpoint": "公开节点",
|
||||
"port": "监听端口",
|
||||
|
@@ -3,17 +3,13 @@ import { defineStore } from 'pinia'
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
import { apiWrapper } from '@/helpers/fetch-wrapper'
|
||||
import router from '../router'
|
||||
import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser';
|
||||
import {base64_url_encode} from "@/helpers/encoding";
|
||||
|
||||
export const authStore = defineStore('auth',{
|
||||
state: () => ({
|
||||
// initialize state from local storage to enable user to stay logged in
|
||||
user: JSON.parse(localStorage.getItem('user')),
|
||||
providers: [],
|
||||
returnUrl: localStorage.getItem('returnUrl'),
|
||||
webAuthnCredentials: [],
|
||||
fetching: false,
|
||||
returnUrl: localStorage.getItem('returnUrl')
|
||||
}),
|
||||
getters: {
|
||||
UserIdentifier: (state) => state.user?.Identifier || 'unknown',
|
||||
@@ -22,14 +18,6 @@ export const authStore = defineStore('auth',{
|
||||
IsAuthenticated: (state) => state.user != null,
|
||||
IsAdmin: (state) => state.user?.IsAdmin || false,
|
||||
ReturnUrl: (state) => state.returnUrl || '/',
|
||||
IsWebAuthnEnabled: (state) => {
|
||||
if (state.webAuthnCredentials) {
|
||||
return state.webAuthnCredentials.length > 0
|
||||
}
|
||||
return false
|
||||
},
|
||||
WebAuthnCredentials: (state) => state.webAuthnCredentials || [],
|
||||
isFetching: (state) => state.fetching,
|
||||
},
|
||||
actions: {
|
||||
SetReturnUrl(link) {
|
||||
@@ -72,23 +60,6 @@ export const authStore = defineStore('auth',{
|
||||
return Promise.reject(err)
|
||||
})
|
||||
},
|
||||
// LoadWebAuthnCredentials returns promise that might have been rejected if the session was not authenticated.
|
||||
async LoadWebAuthnCredentials() {
|
||||
this.fetching = true
|
||||
return apiWrapper.get(`/auth/webauthn/credentials`)
|
||||
.then(credentials => {
|
||||
this.setWebAuthnCredentials(credentials)
|
||||
})
|
||||
.catch(error => {
|
||||
this.setWebAuthnCredentials([])
|
||||
console.log("Failed to load webauthn credentials:", error)
|
||||
notify({
|
||||
title: "Backend Connection Failure",
|
||||
text: error,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
},
|
||||
// Login returns promise that might have been rejected if the login attempt was not successful.
|
||||
async Login(username, password) {
|
||||
return apiWrapper.post(`/auth/login`, { username, password })
|
||||
@@ -122,157 +93,6 @@ export const authStore = defineStore('auth',{
|
||||
|
||||
await router.push('/login')
|
||||
},
|
||||
async RegisterWebAuthn() {
|
||||
// check if the browser supports WebAuthn
|
||||
if (!browserSupportsWebAuthn()) {
|
||||
console.error("WebAuthn is not supported by this browser.");
|
||||
notify({
|
||||
title: "WebAuthn not supported",
|
||||
text: "This browser does not support WebAuthn.",
|
||||
type: 'error'
|
||||
});
|
||||
return Promise.reject(new Error("WebAuthn not supported"));
|
||||
}
|
||||
|
||||
this.fetching = true
|
||||
console.log("Starting WebAuthn registration...")
|
||||
await apiWrapper.post(`/auth/webauthn/register/start`, {})
|
||||
.then(optionsJSON => {
|
||||
notify({
|
||||
title: "Passkey registration",
|
||||
text: "Starting passkey registration, follow the instructions in the browser."
|
||||
});
|
||||
console.log("Started WebAuthn registration with options: ", optionsJSON)
|
||||
|
||||
return startRegistration({ optionsJSON: optionsJSON.publicKey }).then(attResp => {
|
||||
console.log("Finishing WebAuthn registration...")
|
||||
return apiWrapper.post(`/auth/webauthn/register/finish`, attResp)
|
||||
.then(credentials => {
|
||||
console.log("Passkey registration finished successfully: ", credentials)
|
||||
this.setWebAuthnCredentials(credentials)
|
||||
notify({
|
||||
title: "Passkey registration",
|
||||
text: "A new passkey has been registered successfully!",
|
||||
type: 'success'
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
this.fetching = false
|
||||
console.error("Failed to register passkey:", err);
|
||||
notify({
|
||||
title: "Passkey registration failed",
|
||||
text: err,
|
||||
type: 'error'
|
||||
});
|
||||
})
|
||||
}).catch(err => {
|
||||
this.fetching = false
|
||||
console.error("Failed to start WebAuthn registration:", err);
|
||||
notify({
|
||||
title: "Failed to start Passkey registration",
|
||||
text: err,
|
||||
type: 'error'
|
||||
});
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
this.fetching = false
|
||||
console.error("Failed to start WebAuthn registration:", err);
|
||||
notify({
|
||||
title: "Failed to start WebAuthn registration",
|
||||
text: err,
|
||||
type: 'error'
|
||||
});
|
||||
})
|
||||
},
|
||||
async DeleteWebAuthnCredential(credentialId) {
|
||||
this.fetching = true
|
||||
return apiWrapper.delete(`/auth/webauthn/credential/${base64_url_encode(credentialId)}`)
|
||||
.then(credentials => {
|
||||
this.setWebAuthnCredentials(credentials)
|
||||
notify({
|
||||
title: "Success",
|
||||
text: "Passkey deleted successfully!",
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
this.fetching = false
|
||||
console.error("Failed to delete webauthn credential:", err);
|
||||
notify({
|
||||
title: "Backend Connection Failure",
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
},
|
||||
async RenameWebAuthnCredential(credential) {
|
||||
this.fetching = true
|
||||
return apiWrapper.put(`/auth/webauthn/credential/${base64_url_encode(credential.ID)}`, {
|
||||
Name: credential.Name,
|
||||
})
|
||||
.then(credentials => {
|
||||
this.setWebAuthnCredentials(credentials)
|
||||
notify({
|
||||
title: "Success",
|
||||
text: "Passkey renamed successfully!",
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
this.fetching = false
|
||||
console.error("Failed to rename webauthn credential", credential.ID, ":", err);
|
||||
notify({
|
||||
title: "Backend Connection Failure",
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
},
|
||||
async LoginWebAuthn() {
|
||||
// check if the browser supports WebAuthn
|
||||
if (!browserSupportsWebAuthn()) {
|
||||
console.error("WebAuthn is not supported by this browser.");
|
||||
notify({
|
||||
title: "WebAuthn not supported",
|
||||
text: "This browser does not support WebAuthn.",
|
||||
type: 'error'
|
||||
});
|
||||
return Promise.reject(new Error("WebAuthn not supported"));
|
||||
}
|
||||
|
||||
this.fetching = true
|
||||
console.log("Starting WebAuthn login...")
|
||||
await apiWrapper.post(`/auth/webauthn/login/start`, {})
|
||||
.then(optionsJSON => {
|
||||
console.log("Started WebAuthn login with options: ", optionsJSON)
|
||||
|
||||
return startAuthentication({ optionsJSON: optionsJSON.publicKey }).then(asseResp => {
|
||||
console.log("Finishing WebAuthn login ...")
|
||||
return apiWrapper.post(`/auth/webauthn/login/finish`, asseResp)
|
||||
.then(user => {
|
||||
console.log("Passkey login finished successfully for user:", user.Identifier)
|
||||
this.ResetReturnUrl()
|
||||
this.setUserInfo(user)
|
||||
return user.Identifier
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to login with passkey:", err)
|
||||
this.setUserInfo(null)
|
||||
return Promise.reject(new Error("login failed"))
|
||||
})
|
||||
}).catch(err => {
|
||||
console.error("Failed to finish passkey login:", err)
|
||||
this.setUserInfo(null)
|
||||
return Promise.reject(new Error("login failed"))
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to start passkey login:", err)
|
||||
this.setUserInfo(null)
|
||||
return Promise.reject(new Error("login failed"))
|
||||
})
|
||||
},
|
||||
// -- internal setters
|
||||
setUserInfo(userInfo) {
|
||||
// store user details and jwt in local storage to keep user logged in between page refreshes
|
||||
@@ -300,9 +120,5 @@ export const authStore = defineStore('auth',{
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
},
|
||||
setWebAuthnCredentials(credentials) {
|
||||
this.fetching = false
|
||||
this.webAuthnCredentials = credentials
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@@ -142,8 +142,8 @@ export const peerStore = defineStore('peers', {
|
||||
})
|
||||
})
|
||||
},
|
||||
async MailPeerConfig(linkOnly, style, ids) {
|
||||
return apiWrapper.post(`${baseUrl}/config-mail?style=${style}`, {
|
||||
async MailPeerConfig(linkOnly, ids) {
|
||||
return apiWrapper.post(`${baseUrl}/config-mail`, {
|
||||
Identifiers: ids,
|
||||
LinkOnly: linkOnly
|
||||
})
|
||||
@@ -158,8 +158,8 @@ export const peerStore = defineStore('peers', {
|
||||
throw new Error(error)
|
||||
})
|
||||
},
|
||||
async LoadPeerConfig(id, style) {
|
||||
return apiWrapper.get(`${baseUrl}/config/${base64_url_encode(id)}?style=${style}`)
|
||||
async LoadPeerConfig(id) {
|
||||
return apiWrapper.get(`${baseUrl}/config/${base64_url_encode(id)}`)
|
||||
.then(this.setPeerConfig)
|
||||
.catch(error => {
|
||||
this.configuration = ""
|
||||
|
@@ -26,7 +26,7 @@ onMounted(async () => {
|
||||
<div class="form-group d-inline">
|
||||
<div class="input-group mb-3">
|
||||
<input v-model="audit.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="audit.afterPageSizeChange">
|
||||
<button class="btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
|
||||
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -13,21 +13,21 @@ const auth = authStore()
|
||||
<p class="lead">{{ $t('home.abstract') }}</p>
|
||||
|
||||
|
||||
<div class="card border-secondary p-5" v-if="auth.IsAuthenticated">
|
||||
<div class="bg-light p-5" v-if="auth.IsAuthenticated">
|
||||
<h2 class="display-5">{{ $t('home.profiles.headline') }}</h2>
|
||||
<p class="lead">{{ $t('home.profiles.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
<p class="card-text">{{ $t('home.profiles.content') }}</p>
|
||||
<p>{{ $t('home.profiles.content') }}</p>
|
||||
<p class="lead">
|
||||
<RouterLink :to="{ name: 'profile' }" class="btn btn-primary btn-lg">{{ $t('home.profiles.button') }}</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card border-secondary p-5 mt-4" v-if="auth.IsAuthenticated && auth.IsAdmin">
|
||||
<div class="bg-light p-5 mt-4" v-if="auth.IsAuthenticated && auth.IsAdmin">
|
||||
<h2 class="display-5">{{ $t('home.admin.headline') }}</h2>
|
||||
<p class="lead">{{ $t('home.admin.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
<p class="card-text">{{ $t('home.admin.content') }}</p>
|
||||
<p>{{ $t('home.admin.content') }}</p>
|
||||
<p class="lead">
|
||||
<RouterLink :to="{ name: 'interfaces' }" class="btn btn-primary btn-lg me-2">{{ $t('home.admin.button-admin') }}
|
||||
</RouterLink>
|
||||
@@ -65,7 +65,7 @@ const auth = authStore()
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h4 class="card-title">{{ $t('home.about-portal.headline') }}</h4>
|
||||
<p class="card-text">{{ $t('home.about-portal.content') }}</p>
|
||||
<a href="https://wgportal.org/" title="WireGuard Portal" target="_blank"
|
||||
<a href="https://github.com/h44z/wg-portal/" title="WireGuard Portal" target="_blank"
|
||||
rel="noopener noreferrer" class="mt-auto btn btn-primary btn-sm">{{ $t('home.about-portal.button') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -5,20 +5,17 @@ import PeerMultiCreateModal from "../components/PeerMultiCreateModal.vue";
|
||||
import InterfaceEditModal from "../components/InterfaceEditModal.vue";
|
||||
import InterfaceViewModal from "../components/InterfaceViewModal.vue";
|
||||
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import {onMounted, ref} from "vue";
|
||||
import {peerStore} from "@/stores/peers";
|
||||
import {interfaceStore} from "@/stores/interfaces";
|
||||
import {notify} from "@kyvg/vue3-notification";
|
||||
import {settingsStore} from "@/stores/settings";
|
||||
import {humanFileSize} from '@/helpers/utils';
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
const settings = settingsStore()
|
||||
const interfaces = interfaceStore()
|
||||
const peers = peerStore()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const viewedPeerId = ref("")
|
||||
const editPeerId = ref("")
|
||||
const multiCreatePeerId = ref("")
|
||||
@@ -48,33 +45,6 @@ function calculateInterfaceName(id, name) {
|
||||
return result
|
||||
}
|
||||
|
||||
const calculateBackendName = computed(() => {
|
||||
let backendId = interfaces.GetSelected.Backend
|
||||
|
||||
let backendName = t('interfaces.interface.unknown-backend')
|
||||
let availableBackends = settings.Setting('AvailableBackends') || []
|
||||
availableBackends.forEach(backend => {
|
||||
if (backend.Id === backendId) {
|
||||
backendName = backend.Id === 'local' ? t(backend.Name) : backend.Name
|
||||
}
|
||||
})
|
||||
return backendName
|
||||
})
|
||||
|
||||
const isBackendValid = computed(() => {
|
||||
let backendId = interfaces.GetSelected.Backend
|
||||
|
||||
let valid = false
|
||||
let availableBackends = settings.Setting('AvailableBackends') || []
|
||||
availableBackends.forEach(backend => {
|
||||
if (backend.Id === backendId) {
|
||||
valid = true
|
||||
}
|
||||
})
|
||||
return valid
|
||||
})
|
||||
|
||||
|
||||
async function download() {
|
||||
await interfaces.LoadInterfaceConfig(interfaces.GetSelected.Identifier)
|
||||
|
||||
@@ -142,7 +112,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-group mb-3">
|
||||
<button class="btn btn-primary" :title="$t('interfaces.button-add-interface')" @click.prevent="editInterfaceId='#NEW#'">
|
||||
<button class="input-group-text btn btn-primary" :title="$t('interfaces.button-add-interface')" @click.prevent="editInterfaceId='#NEW#'">
|
||||
<i class="fa-solid fa-plus-circle"></i>
|
||||
</button>
|
||||
<select v-model="interfaces.selected" :disabled="interfaces.Count===0" class="form-select" @change="() => { peers.LoadPeers(); peers.LoadStats() }">
|
||||
@@ -171,7 +141,7 @@ onMounted(async () => {
|
||||
<div class="card-header">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8">
|
||||
{{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{ $t('modals.interface-edit.mode.' + interfaces.GetSelected.Mode )}} | {{ $t('interfaces.interface.backend') + ": " + calculateBackendName }}<span v-if="!isBackendValid" :title="t('interfaces.interface.wrong-backend')" class="ms-1 me-1"><i class="fa-solid fa-triangle-exclamation"></i></span>)
|
||||
{{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{interfaces.GetSelected.Mode}} {{ $t('interfaces.interface.mode') }})
|
||||
<span v-if="interfaces.GetSelected.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="interfaces.GetSelected.DisabledReason"></i></span>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4 text-lg-end">
|
||||
@@ -344,7 +314,7 @@ onMounted(async () => {
|
||||
<div class="form-group d-inline">
|
||||
<div class="input-group mb-3">
|
||||
<input v-model="peers.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="peers.afterPageSizeChange">
|
||||
<button class="btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
|
||||
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -459,5 +429,3 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style>
|
||||
</style>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import {computed, ref} from "vue";
|
||||
import {authStore} from "@/stores/auth";
|
||||
import router from '../router/index.js'
|
||||
import {notify} from "@kyvg/vue3-notification";
|
||||
@@ -16,14 +16,6 @@ const password = ref("")
|
||||
const usernameInvalid = computed(() => username.value === "")
|
||||
const passwordInvalid = computed(() => password.value === "")
|
||||
const disableLoginBtn = computed(() => username.value === "" || password.value === "" || loggingIn.value)
|
||||
const showLoginForm = computed(() => {
|
||||
console.log(router.currentRoute.value.query)
|
||||
return settings.Setting('LoginFormVisible') || router.currentRoute.value.query.hasOwnProperty('all');
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await settings.LoadSettings()
|
||||
})
|
||||
|
||||
const login = async function () {
|
||||
console.log("Performing login for user:", username.value);
|
||||
@@ -36,34 +28,7 @@ const login = async function () {
|
||||
type: 'success',
|
||||
});
|
||||
loggingIn.value = false;
|
||||
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
|
||||
settings.LoadSettings(); // only logs errors, does not throw
|
||||
router.push(auth.ReturnUrl);
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -101,7 +66,7 @@ const externalLogin = function (provider) {
|
||||
</div></div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<fieldset v-if="showLoginForm">
|
||||
<fieldset>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="inputUsername">{{ $t('login.username.label') }}</label>
|
||||
<div class="input-group mb-3">
|
||||
@@ -120,42 +85,13 @@ const externalLogin = function (provider) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-5 mb-2">
|
||||
<div class="col-sm-4 col-xs-12">
|
||||
<button :disabled="disableLoginBtn" class="btn btn-primary mb-2" type="submit" @click.prevent="login">
|
||||
<div class="row mt-5 d-flex">
|
||||
<div :class="{'col-lg-4':auth.LoginProviders.length < 3, 'col-lg-12':auth.LoginProviders.length >= 3}" class="d-flex mb-2">
|
||||
<button :disabled="disableLoginBtn" class="btn btn-primary flex-fill" type="submit" @click.prevent="login">
|
||||
{{ $t('login.button') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-sm-8 col-xs-12 text-sm-end">
|
||||
<button v-if="settings.Setting('WebAuthnEnabled')" class="btn btn-primary" type="submit" @click.prevent="loginWebAuthn">
|
||||
{{ $t('login.button-webauthn') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4 d-flex">
|
||||
<div class="col-lg-12 d-flex mb-2">
|
||||
<!-- OpenIdConnect / OAUTH providers -->
|
||||
<button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}"
|
||||
:disabled="loggingIn" :title="provider.Name" class="btn btn-outline-primary flex-fill"
|
||||
v-html="provider.Name" @click.prevent="externalLogin(provider)"></button>
|
||||
</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">
|
||||
<div :class="{'col-lg-8':auth.LoginProviders.length < 3, 'col-lg-12':auth.LoginProviders.length >= 3}" class="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"
|
||||
@@ -168,6 +104,7 @@ const externalLogin = function (provider) {
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -65,7 +65,7 @@ onMounted(async () => {
|
||||
<div class="input-group mb-3">
|
||||
<input v-model="profile.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text"
|
||||
@keyup="profile.afterPageSizeChange">
|
||||
<button class="btn btn-primary" :title="$t('general.search.button')"><i
|
||||
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i
|
||||
class="fa-solid fa-search"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,7 +73,7 @@ onMounted(async () => {
|
||||
<div class="col-12 col-lg-3 text-lg-end">
|
||||
<div class="form-group" v-if="settings.Setting('SelfProvisioning')">
|
||||
<div class="input-group mb-3">
|
||||
<button class="btn btn-primary" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId = '#NEW#'">
|
||||
<button class="input-group-text btn btn-primary" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId = '#NEW#'">
|
||||
<i class="fa fa-plus me-1"></i><i class="fa fa-user"></i>
|
||||
</button>
|
||||
<select v-model="profile.selectedInterfaceId" :disabled="profile.CountInterfaces===0" class="form-select">
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import {onMounted, ref} from "vue";
|
||||
import { onMounted } from "vue";
|
||||
import { profileStore } from "@/stores/profile";
|
||||
import { settingsStore } from "@/stores/settings";
|
||||
import { authStore } from "../stores/auth";
|
||||
@@ -10,30 +10,8 @@ const auth = authStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await profile.LoadUser()
|
||||
await auth.LoadWebAuthnCredentials()
|
||||
})
|
||||
|
||||
const selectedCredential = ref({})
|
||||
|
||||
function enableRename(credential) {
|
||||
credential.renameMode = true;
|
||||
credential.tempName = credential.Name; // Store the original name
|
||||
}
|
||||
|
||||
function cancelRename(credential) {
|
||||
credential.renameMode = false;
|
||||
credential.tempName = null; // Discard changes
|
||||
}
|
||||
|
||||
async function saveRename(credential) {
|
||||
try {
|
||||
await auth.RenameWebAuthnCredential({ ...credential, Name: credential.tempName });
|
||||
credential.Name = credential.tempName; // Update the name
|
||||
credential.renameMode = false;
|
||||
} catch (error) {
|
||||
console.error("Failed to rename credential:", error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,7 +22,7 @@ async function saveRename(credential) {
|
||||
<p class="lead">{{ $t('settings.abstract') }}</p>
|
||||
|
||||
<div v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')">
|
||||
<div class="card border-secondary p-5" v-if="profile.user.ApiToken">
|
||||
<div class="bg-light p-5" v-if="profile.user.ApiToken">
|
||||
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
|
||||
<p class="lead">{{ $t('settings.api.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
@@ -72,7 +50,7 @@ async function saveRename(credential) {
|
||||
</div>
|
||||
<div class="row mt-5">
|
||||
<div class="col-6">
|
||||
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching">
|
||||
<button class="input-group-text btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching">
|
||||
<i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -81,96 +59,14 @@ async function saveRename(credential) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card border-secondary p-5" v-else>
|
||||
<div class="bg-light p-5" v-else>
|
||||
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
|
||||
<p class="lead">{{ $t('settings.api.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
<p>{{ $t('settings.api.inactive-description') }}</p>
|
||||
<button class="btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching">
|
||||
<button class="input-group-text btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching">
|
||||
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-secondary p-5 mt-5" v-if="settings.Setting('WebAuthnEnabled')">
|
||||
<h2 class="display-7">{{ $t('settings.webauthn.headline') }}</h2>
|
||||
<p class="lead">{{ $t('settings.webauthn.abstract') }}</p>
|
||||
<hr class="my-4">
|
||||
<p v-if="auth.IsWebAuthnEnabled">{{ $t('settings.webauthn.active-description') }}</p>
|
||||
<p v-else>{{ $t('settings.webauthn.inactive-description') }}</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<button class="btn btn-primary" :title="$t('settings.webauthn.button-register-text')" @click.prevent="auth.RegisterWebAuthn" :disabled="auth.isFetching">
|
||||
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.webauthn.button-register-title') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.WebAuthnCredentials.length > 0" class="mt-4">
|
||||
<h3>{{ $t('settings.webauthn.credentials-list') }}</h3>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%">{{ $t('settings.webauthn.table.name') }}</th>
|
||||
<th style="width: 20%">{{ $t('settings.webauthn.table.created') }}</th>
|
||||
<th style="width: 30%">{{ $t('settings.webauthn.table.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="credential in auth.webAuthnCredentials" :key="credential.ID">
|
||||
<td class="align-middle">
|
||||
<div v-if="credential.renameMode">
|
||||
<input v-model="credential.tempName" class="form-control" type="text" />
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ credential.Name }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{{ credential.CreatedAt }}
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
<div v-if="credential.renameMode">
|
||||
<button class="btn btn-success me-1" :title="$t('settings.webauthn.button-save-text')" @click.prevent="saveRename(credential)" :disabled="auth.isFetching">
|
||||
{{ $t('settings.webauthn.button-save-title') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" :title="$t('settings.webauthn.button-cancel-text')" @click.prevent="cancelRename(credential)">
|
||||
{{ $t('settings.webauthn.button-cancel-title') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<button class="btn btn-secondary me-1" :title="$t('settings.webauthn.button-rename-text')" @click.prevent="enableRename(credential)">
|
||||
{{ $t('settings.webauthn.button-rename-title') }}
|
||||
</button>
|
||||
<button class="btn btn-danger" :title="$t('settings.webauthn.button-delete-text')" data-bs-toggle="modal" data-bs-target="#webAuthnDeleteModal" :disabled="auth.isFetching" @click="selectedCredential=credential">
|
||||
{{ $t('settings.webauthn.button-delete-title') }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="webAuthnDeleteModal" tabindex="-1" aria-labelledby="webAuthnDeleteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="webAuthnDeleteModalLabel">{{ $t('settings.webauthn.modal-delete.headline') }}</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" :aria-label="$t('settings.webauthn.modal-delete.button-cancel')"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5 class="mb-3">{{ selectedCredential.Name }} <small class="text-body-secondary">({{ $t('settings.webauthn.modal-delete.created') }} {{ selectedCredential.CreatedAt }})</small></h5>
|
||||
<p class="mb-0">{{ $t('settings.webauthn.modal-delete.abstract') }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ $t('settings.webauthn.modal-delete.button-cancel') }}</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmWebAuthnDelete" @click="auth.DeleteWebAuthnCredential(selectedCredential.ID)" :disabled="auth.isFetching" data-bs-dismiss="modal">{{ $t('settings.webauthn.modal-delete.button-delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
@@ -35,7 +35,7 @@ onMounted(() => {
|
||||
<div class="form-group d-inline">
|
||||
<div class="input-group mb-3">
|
||||
<input v-model="users.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="users.afterPageSizeChange">
|
||||
<button class="btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
|
||||
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
62
go.mod
62
go.mod
@@ -4,32 +4,31 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/a8m/envsubst v1.4.3
|
||||
github.com/alexedwards/scs/v2 v2.9.0
|
||||
github.com/coreos/go-oidc/v3 v3.15.0
|
||||
github.com/alexedwards/scs/v2 v2.8.0
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.11
|
||||
github.com/go-pkgz/routegroup v1.5.3
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
github.com/go-webauthn/webauthn v0.13.4
|
||||
github.com/go-pkgz/routegroup v1.4.1
|
||||
github.com/go-playground/validator/v10 v10.26.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/prometheus-community/pro-bing v0.7.0
|
||||
github.com/prometheus/client_golang v1.23.0
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/swaggo/swag v1.16.6
|
||||
github.com/swaggo/swag v1.16.4
|
||||
github.com/vardius/message-bus v1.1.5
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5
|
||||
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/sys v0.35.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlserver v1.6.1
|
||||
gorm.io/gorm v1.30.2
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/driver/sqlserver v1.5.4
|
||||
gorm.io/gorm v1.26.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -40,28 +39,24 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||
github.com/go-test/deep v1.1.1 // indirect
|
||||
github.com/go-webauthn/x v0.1.23 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.4 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -73,30 +68,27 @@ require (
|
||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.2 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.8.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/prometheus/common v0.63.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250808145144-a408d31f581a // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
golang.org/x/tools v0.32.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
modernc.org/libc v1.66.6 // indirect
|
||||
modernc.org/libc v1.63.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.38.2 // indirect
|
||||
modernc.org/memory v1.10.0 // indirect
|
||||
modernc.org/sqlite v1.37.0 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
)
|
||||
|
235
go.sum
235
go.sum
@@ -2,45 +2,41 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
|
||||
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 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw=
|
||||
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.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 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY=
|
||||
github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
|
||||
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
||||
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
|
||||
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -48,8 +44,6 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
@@ -62,50 +56,44 @@ github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0
|
||||
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
||||
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
|
||||
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
|
||||
github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA=
|
||||
github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
||||
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
|
||||
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
|
||||
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
|
||||
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
|
||||
github.com/go-pkgz/routegroup v1.5.3 h1:IvH1KLcQkMap9jucQGBlef3IBloxSAe8USUFvxShFqs=
|
||||
github.com/go-pkgz/routegroup v1.5.3/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
|
||||
github.com/go-pkgz/routegroup v1.4.1 h1:iw1yW3lXuurZZOv/DF9fY8Mkpvy6J9UjBiP1oDIQE/s=
|
||||
github.com/go-pkgz/routegroup v1.4.1/go.mod h1:kDDPDRLRiRY1vnENrZJw1jQAzQX7fvsbsHGRQFNQfKc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
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/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
|
||||
github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
|
||||
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
|
||||
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
@@ -117,8 +105,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
@@ -143,11 +131,8 @@ github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtL
|
||||
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -164,13 +149,11 @@ github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/
|
||||
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
|
||||
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
||||
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
|
||||
github.com/microsoft/go-mssqldb v1.9.2 h1:nY8TmFMQOHpm2qVWo6y4I2mAmVdZqlGiMGAYt64Ibbs=
|
||||
github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
|
||||
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
||||
github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw=
|
||||
github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo=
|
||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
|
||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
||||
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -180,28 +163,25 @@ github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
|
||||
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
|
||||
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
|
||||
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
|
||||
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -209,11 +189,10 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
|
||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||
@@ -223,8 +202,6 @@ 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/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||
@@ -234,32 +211,23 @@ github.com/yeqown/go-qrcode/writer/compressed v1.0.1/go.mod h1:BJScsGUIKM+eg0CCL
|
||||
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
||||
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20250808145144-a408d31f581a h1:Y+7uR/b1Mw2iSXZ3G//1haIiSElDQZ8KWh0h+sZPG90=
|
||||
golang.org/x/exp v0.0.0-20250808145144-a408d31f581a/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -270,26 +238,18 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -303,16 +263,11 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -320,13 +275,9 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -337,22 +288,17 @@ golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
||||
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
@@ -367,37 +313,36 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY=
|
||||
gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
gorm.io/gorm v1.30.2 h1:f7bevlVoVe4Byu3pmbWPVHnPsLoWaMjEb7/clyr9Ivs=
|
||||
gorm.io/gorm v1.30.2/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
|
||||
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g=
|
||||
gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g=
|
||||
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
|
||||
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
|
||||
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
|
||||
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.6 h1:RyQpwAhM/19nXD8y3iejM/AjmKwY2TjxZTlUWTsWw2U=
|
||||
modernc.org/libc v1.66.6/go.mod h1:j8z0EYAuumoMQ3+cWXtmw6m+LYn3qm8dcZDFtFTSq+M=
|
||||
modernc.org/libc v1.63.0 h1:wKzb61wOGCzgahQBORb1b0dZonh8Ufzl/7r4Yf1D5YA=
|
||||
modernc.org/libc v1.63.0/go.mod h1:wDzH1mgz1wUIEwottFt++POjGRO9sgyQKrpXaz3x89E=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
@@ -220,8 +220,6 @@ func (r *SqlRepo) preCheck() error {
|
||||
func (r *SqlRepo) migrate() error {
|
||||
slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{}))
|
||||
slog.Debug("running migration: user", "result", r.db.AutoMigrate(&domain.User{}))
|
||||
slog.Debug("running migration: user webauthn credentials", "result",
|
||||
r.db.AutoMigrate(&domain.UserWebauthnCredential{}))
|
||||
slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{}))
|
||||
slog.Debug("running migration: peer", "result", r.db.AutoMigrate(&domain.Peer{}))
|
||||
slog.Debug("running migration: peer status", "result", r.db.AutoMigrate(&domain.PeerStatus{}))
|
||||
@@ -748,7 +746,7 @@ func (r *SqlRepo) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr
|
||||
func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
|
||||
var user domain.User
|
||||
|
||||
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").First(&user, id).Error
|
||||
err := r.db.WithContext(ctx).First(&user, id).Error
|
||||
|
||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, domain.ErrNotFound
|
||||
@@ -766,7 +764,7 @@ func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domai
|
||||
func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
var users []domain.User
|
||||
|
||||
err := r.db.WithContext(ctx).Where("email = ?", email).Preload("WebAuthnCredentialList").Find(&users).Error
|
||||
err := r.db.WithContext(ctx).Where("email = ?", email).Find(&users).Error
|
||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
@@ -787,26 +785,11 @@ func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.Use
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByWebAuthnCredential returns the user with the given webauthn credential id.
|
||||
func (r *SqlRepo) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) {
|
||||
var credential domain.UserWebauthnCredential
|
||||
|
||||
err := r.db.WithContext(ctx).Where("credential_identifier = ?", credentialIdBase64).First(&credential).Error
|
||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.GetUser(ctx, domain.UserIdentifier(credential.UserIdentifier))
|
||||
}
|
||||
|
||||
// GetAllUsers returns all users.
|
||||
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
||||
var users []domain.User
|
||||
|
||||
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Find(&users).Error
|
||||
err := r.db.WithContext(ctx).Find(&users).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -825,7 +808,6 @@ func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User,
|
||||
Or("firstname LIKE ?", searchValue).
|
||||
Or("lastname LIKE ?", searchValue).
|
||||
Or("email LIKE ?", searchValue).
|
||||
Preload("WebAuthnCredentialList").
|
||||
Find(&users).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -871,7 +853,7 @@ func (r *SqlRepo) SaveUser(
|
||||
|
||||
// DeleteUser deletes the user with the given id.
|
||||
func (r *SqlRepo) DeleteUser(ctx context.Context, id domain.UserIdentifier) error {
|
||||
err := r.db.WithContext(ctx).Unscoped().Select(clause.Associations).Delete(&domain.User{Identifier: id}).Error
|
||||
err := r.db.WithContext(ctx).Delete(&domain.User{}, id).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -915,11 +897,6 @@ func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *doma
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Session(&gorm.Session{FullSaveAssociations: true}).Unscoped().Model(user).Association("WebAuthnCredentialList").Unscoped().Replace(user.WebAuthnCredentialList)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update users webauthn credentials: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@@ -133,5 +133,5 @@ func (m *MetricsServer) UpdatePeerMetrics(peer *domain.Peer, status domain.PeerS
|
||||
}
|
||||
m.peerReceivedBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesReceived))
|
||||
m.peerSendBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesTransmitted))
|
||||
m.peerIsConnected.WithLabelValues(labels...).Set(internal.BoolToFloat64(status.IsConnected))
|
||||
m.peerIsConnected.WithLabelValues(labels...).Set(internal.BoolToFloat64(status.IsConnected()))
|
||||
}
|
||||
|
@@ -1,864 +0,0 @@
|
||||
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
|
@@ -1,829 +0,0 @@
|
||||
package wgcontroller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"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 := 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,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
type WgRepo struct {
|
||||
wg lowlevel.WireGuardClient
|
||||
nl lowlevel.NetlinkClient
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewWireGuardRepository creates a new WgRepo instance.
|
||||
@@ -35,7 +33,6 @@ func NewWireGuardRepository() *WgRepo {
|
||||
repo := &WgRepo{
|
||||
wg: wg,
|
||||
nl: nl,
|
||||
log: slog.Default().With(slog.String("adapter", "wireguard")),
|
||||
}
|
||||
|
||||
return repo
|
||||
@@ -43,10 +40,8 @@ func NewWireGuardRepository() *WgRepo {
|
||||
|
||||
// GetInterfaces returns all existing WireGuard interfaces.
|
||||
func (r *WgRepo) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) {
|
||||
r.log.Debug("getting all interfaces")
|
||||
devices, err := r.wg.Devices()
|
||||
if err != nil {
|
||||
r.log.Error("failed to get devices", "error", err)
|
||||
return nil, fmt.Errorf("device list error: %w", err)
|
||||
}
|
||||
|
||||
@@ -65,17 +60,14 @@ func (r *WgRepo) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, e
|
||||
// GetInterface returns the interface with the given id.
|
||||
// If no interface is found, an error os.ErrNotExist is returned.
|
||||
func (r *WgRepo) GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) {
|
||||
r.log.Debug("getting interface", "id", id)
|
||||
return r.getInterface(id)
|
||||
}
|
||||
|
||||
// GetPeers returns all peers associated with the given interface id.
|
||||
// If the requested interface is found, an error os.ErrNotExist is returned.
|
||||
func (r *WgRepo) GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) {
|
||||
r.log.Debug("getting peers for interface", "deviceId", deviceId)
|
||||
device, err := r.wg.Device(string(deviceId))
|
||||
if err != nil {
|
||||
r.log.Error("failed to get device", "deviceId", deviceId, "error", err)
|
||||
return nil, fmt.Errorf("device error: %w", err)
|
||||
}
|
||||
|
||||
@@ -98,7 +90,6 @@ func (r *WgRepo) GetPeer(
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
) (*domain.PhysicalPeer, error) {
|
||||
r.log.Debug("getting peer", "deviceId", deviceId, "peerId", id)
|
||||
return r.getPeer(deviceId, id)
|
||||
}
|
||||
|
||||
@@ -183,31 +174,25 @@ func (r *WgRepo) SaveInterface(
|
||||
id domain.InterfaceIdentifier,
|
||||
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
|
||||
) error {
|
||||
r.log.Debug("saving interface", "id", id)
|
||||
physicalInterface, err := r.getOrCreateInterface(id)
|
||||
if err != nil {
|
||||
r.log.Error("failed to get or create interface", "id", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if updateFunc != nil {
|
||||
physicalInterface, err = updateFunc(physicalInterface)
|
||||
if err != nil {
|
||||
r.log.Error("interface update function failed", "id", id, "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.updateLowLevelInterface(physicalInterface); err != nil {
|
||||
r.log.Error("failed to update low level interface", "id", id, "error", err)
|
||||
return err
|
||||
}
|
||||
if err := r.updateWireGuardInterface(physicalInterface); err != nil {
|
||||
r.log.Error("failed to update wireguard interface", "id", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
r.log.Debug("successfully saved interface", "id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -338,13 +323,10 @@ func (r *WgRepo) updateWireGuardInterface(pi *domain.PhysicalInterface) error {
|
||||
// DeleteInterface deletes the interface with the given id.
|
||||
// If the requested interface is found, no error is returned.
|
||||
func (r *WgRepo) DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error {
|
||||
r.log.Debug("deleting interface", "id", id)
|
||||
if err := r.deleteLowLevelInterface(id); err != nil {
|
||||
r.log.Error("failed to delete low level interface", "id", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
r.log.Debug("successfully deleted interface", "id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -374,25 +356,20 @@ func (r *WgRepo) SavePeer(
|
||||
id domain.PeerIdentifier,
|
||||
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
|
||||
) error {
|
||||
r.log.Debug("saving peer", "deviceId", deviceId, "peerId", id)
|
||||
physicalPeer, err := r.getOrCreatePeer(deviceId, id)
|
||||
if err != nil {
|
||||
r.log.Error("failed to get or create peer", "deviceId", deviceId, "peerId", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
physicalPeer, err = updateFunc(physicalPeer)
|
||||
if err != nil {
|
||||
r.log.Error("peer update function failed", "deviceId", deviceId, "peerId", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.updatePeer(deviceId, physicalPeer); err != nil {
|
||||
r.log.Error("failed to update peer", "deviceId", deviceId, "peerId", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
r.log.Debug("successfully saved peer", "deviceId", deviceId, "peerId", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -464,7 +441,6 @@ func (r *WgRepo) updatePeer(deviceId domain.InterfaceIdentifier, pp *domain.Phys
|
||||
|
||||
err := r.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}})
|
||||
if err != nil {
|
||||
r.log.Error("failed to configure device for peer update", "deviceId", deviceId, "peerId", pp.Identifier, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -474,20 +450,15 @@ func (r *WgRepo) updatePeer(deviceId domain.InterfaceIdentifier, pp *domain.Phys
|
||||
// DeletePeer deletes the peer with the given id.
|
||||
// If the requested interface or peer is found, no error is returned.
|
||||
func (r *WgRepo) DeletePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error {
|
||||
r.log.Debug("deleting peer", "deviceId", deviceId, "peerId", id)
|
||||
if !id.IsPublicKey() {
|
||||
err := errors.New("invalid public key")
|
||||
r.log.Error("invalid peer id", "peerId", id, "error", err)
|
||||
return err
|
||||
return errors.New("invalid public key")
|
||||
}
|
||||
|
||||
err := r.deletePeer(deviceId, id)
|
||||
if err != nil {
|
||||
r.log.Error("failed to delete peer", "deviceId", deviceId, "peerId", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
r.log.Debug("successfully deleted peer", "deviceId", deviceId, "peerId", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -499,7 +470,6 @@ func (r *WgRepo) deletePeer(deviceId domain.InterfaceIdentifier, id domain.PeerI
|
||||
|
||||
err := r.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}})
|
||||
if err != nil {
|
||||
r.log.Error("failed to configure device for peer deletion", "deviceId", deviceId, "peerId", id, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
|
@@ -175,78 +175,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/webauthn/credential/{id}": {
|
||||
"put": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Update a WebAuthn credential.",
|
||||
"operationId": "auth_handleWebAuthnCredentialsPut",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Base64 encoded Credential ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Credential name",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.WebAuthnCredentialRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.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/webauthn/credentials": {
|
||||
"/auth/{provider}/callback": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -254,67 +183,38 @@
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Get all available external login providers.",
|
||||
"operationId": "auth_handleWebAuthnCredentialsGet",
|
||||
"summary": "Handle the OAuth callback.",
|
||||
"operationId": "auth_handleOauthCallbackGet",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.WebAuthnCredentialResponse"
|
||||
"$ref": "#/definitions/model.LoginProviderInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/webauthn/login/finish": {
|
||||
"post": {
|
||||
"/auth/{provider}/init": {
|
||||
"get": {
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"summary": "Initiate the OAuth login flow.",
|
||||
"operationId": "auth_handleOauthInitiateGet",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.WebAuthnCredentialResponse"
|
||||
"$ref": "#/definitions/model.LoginProviderInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -819,12 +719,6 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.PeerMailRequest"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The configuration style",
|
||||
"name": "style",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -864,12 +758,6 @@
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The configuration style",
|
||||
"name": "style",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -911,12 +799,6 @@
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The configuration style",
|
||||
"name": "style",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -1781,11 +1663,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Backend": {
|
||||
"description": "the backend used for this interface e.g., local, mikrotik, ...",
|
||||
"type": "string",
|
||||
"example": "local"
|
||||
},
|
||||
"Disabled": {
|
||||
"description": "flag that specifies if the interface is enabled (up) or not (down)",
|
||||
"type": "boolean"
|
||||
@@ -2254,40 +2131,14 @@
|
||||
"ApiAdminOnly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"AvailableBackends": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.SettingsBackendNames"
|
||||
}
|
||||
},
|
||||
"LoginFormVisible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"MailLinkOnly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"MinPasswordLength": {
|
||||
"type": "integer"
|
||||
},
|
||||
"PersistentConfigSupported": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"SelfProvisioning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"WebAuthnEnabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.SettingsBackendNames": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Id": {
|
||||
"type": "string"
|
||||
},
|
||||
"Name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2356,28 +2207,6 @@
|
||||
"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,10 +65,6 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
Backend:
|
||||
description: the backend used for this interface e.g., local, mikrotik, ...
|
||||
example: local
|
||||
type: string
|
||||
Disabled:
|
||||
description: flag that specifies if the interface is enabled (up) or not (down)
|
||||
type: boolean
|
||||
@@ -385,12 +381,6 @@ definitions:
|
||||
properties:
|
||||
ApiAdminOnly:
|
||||
type: boolean
|
||||
AvailableBackends:
|
||||
items:
|
||||
$ref: '#/definitions/model.SettingsBackendNames'
|
||||
type: array
|
||||
LoginFormVisible:
|
||||
type: boolean
|
||||
MailLinkOnly:
|
||||
type: boolean
|
||||
MinPasswordLength:
|
||||
@@ -399,15 +389,6 @@ definitions:
|
||||
type: boolean
|
||||
SelfProvisioning:
|
||||
type: boolean
|
||||
WebAuthnEnabled:
|
||||
type: boolean
|
||||
type: object
|
||||
model.SettingsBackendNames:
|
||||
properties:
|
||||
Id:
|
||||
type: string
|
||||
Name:
|
||||
type: string
|
||||
type: object
|
||||
model.User:
|
||||
properties:
|
||||
@@ -454,20 +435,6 @@ definitions:
|
||||
Source:
|
||||
type: string
|
||||
type: object
|
||||
model.WebAuthnCredentialRequest:
|
||||
properties:
|
||||
Name:
|
||||
type: string
|
||||
type: object
|
||||
model.WebAuthnCredentialResponse:
|
||||
properties:
|
||||
CreatedAt:
|
||||
type: string
|
||||
ID:
|
||||
type: string
|
||||
Name:
|
||||
type: string
|
||||
type: object
|
||||
info:
|
||||
contact:
|
||||
name: WireGuard Portal Developers
|
||||
@@ -583,102 +550,6 @@ paths:
|
||||
summary: Get information about the currently logged-in user.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/webauthn/credential/{id}:
|
||||
delete:
|
||||
operationId: auth_handleWebAuthnCredentialsDelete
|
||||
parameters:
|
||||
- description: Base64 encoded Credential ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.WebAuthnCredentialResponse'
|
||||
type: array
|
||||
summary: Delete a WebAuthn credential.
|
||||
tags:
|
||||
- Authentication
|
||||
put:
|
||||
operationId: auth_handleWebAuthnCredentialsPut
|
||||
parameters:
|
||||
- description: Base64 encoded Credential ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Credential name
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/model.WebAuthnCredentialRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.WebAuthnCredentialResponse'
|
||||
type: array
|
||||
summary: Update a WebAuthn credential.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/webauthn/credentials:
|
||||
get:
|
||||
operationId: auth_handleWebAuthnCredentialsGet
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.WebAuthnCredentialResponse'
|
||||
type: array
|
||||
summary: Get all available external login providers.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/webauthn/login/finish:
|
||||
post:
|
||||
operationId: auth_handleWebAuthnLoginFinish
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/model.User'
|
||||
summary: Finish the WebAuthn login process.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/webauthn/register/finish:
|
||||
post:
|
||||
operationId: auth_handleWebAuthnRegisterFinish
|
||||
parameters:
|
||||
- default: '""'
|
||||
description: Credential name
|
||||
in: query
|
||||
name: credential_name
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.WebAuthnCredentialResponse'
|
||||
type: array
|
||||
summary: Finish the WebAuthn registration process.
|
||||
tags:
|
||||
- Authentication
|
||||
/config/frontend.js:
|
||||
get:
|
||||
operationId: config_handleConfigJsGet
|
||||
@@ -1087,10 +958,6 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/model.PeerMailRequest'
|
||||
- description: The configuration style
|
||||
in: query
|
||||
name: style
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1116,10 +983,6 @@ paths:
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: The configuration style
|
||||
in: query
|
||||
name: style
|
||||
type: string
|
||||
produces:
|
||||
- image/png
|
||||
- application/json
|
||||
@@ -1148,10 +1011,6 @@ paths:
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: The configuration style
|
||||
in: query
|
||||
name: style
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
|
@@ -2086,11 +2086,6 @@
|
||||
"InterfaceIdentifier"
|
||||
],
|
||||
"properties": {
|
||||
"DisplayName": {
|
||||
"description": "DisplayName is an optional name for the new peer.\nIf unset, a default template value (e.g., \"API Peer ...\") will be assigned.",
|
||||
"type": "string",
|
||||
"example": "API Peer xyz"
|
||||
},
|
||||
"InterfaceIdentifier": {
|
||||
"description": "InterfaceIdentifier is the identifier of the WireGuard interface the peer should be linked to.",
|
||||
"type": "string",
|
||||
|
@@ -445,12 +445,6 @@ definitions:
|
||||
type: object
|
||||
models.ProvisioningRequest:
|
||||
properties:
|
||||
DisplayName:
|
||||
description: |-
|
||||
DisplayName is an optional name for the new peer.
|
||||
If unset, a default template value (e.g., "API Peer ...") will be assigned.
|
||||
example: API Peer xyz
|
||||
type: string
|
||||
InterfaceIdentifier:
|
||||
description: InterfaceIdentifier is the identifier of the WireGuard interface
|
||||
the peer should be linked to.
|
||||
|
@@ -100,7 +100,6 @@ func (s *Server) Run(ctx context.Context, listenAddress string) {
|
||||
srvContext, cancelFn := context.WithCancel(ctx)
|
||||
go func() {
|
||||
var err error
|
||||
slog.Debug("starting server", "certFile", s.cfg.Web.CertFile, "keyFile", s.cfg.Web.KeyFile)
|
||||
if s.cfg.Web.CertFile != "" && s.cfg.Web.KeyFile != "" {
|
||||
err = srv.ListenAndServeTLS(s.cfg.Web.CertFile, s.cfg.Web.KeyFile)
|
||||
} else {
|
||||
|
@@ -27,12 +27,12 @@ type PeerServicePeerManager interface {
|
||||
}
|
||||
|
||||
type PeerServiceConfigFileManager interface {
|
||||
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
|
||||
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
|
||||
}
|
||||
|
||||
type PeerServiceMailManager interface {
|
||||
SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error
|
||||
SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error
|
||||
}
|
||||
|
||||
// endregion dependencies
|
||||
@@ -95,24 +95,16 @@ func (p PeerService) DeletePeer(ctx context.Context, id domain.PeerIdentifier) e
|
||||
return p.peers.DeletePeer(ctx, id)
|
||||
}
|
||||
|
||||
func (p PeerService) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) {
|
||||
return p.configFile.GetPeerConfig(ctx, id, style)
|
||||
func (p PeerService) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) {
|
||||
return p.configFile.GetPeerConfig(ctx, id)
|
||||
}
|
||||
|
||||
func (p PeerService) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (
|
||||
io.Reader,
|
||||
error,
|
||||
) {
|
||||
return p.configFile.GetPeerConfigQrCode(ctx, id, style)
|
||||
func (p PeerService) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) {
|
||||
return p.configFile.GetPeerConfigQrCode(ctx, id)
|
||||
}
|
||||
|
||||
func (p PeerService) SendPeerEmail(
|
||||
ctx context.Context,
|
||||
linkOnly bool,
|
||||
style string,
|
||||
peers ...domain.PeerIdentifier,
|
||||
) error {
|
||||
return p.mailer.SendPeerEmail(ctx, linkOnly, style, peers...)
|
||||
func (p PeerService) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error {
|
||||
return p.mailer.SendPeerEmail(ctx, linkOnly, peers...)
|
||||
}
|
||||
|
||||
func (p PeerService) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) {
|
||||
|
@@ -99,8 +99,6 @@ type Authenticator interface {
|
||||
LoggedIn(scopes ...Scope) func(next http.Handler) http.Handler
|
||||
// UserIdMatch checks if the user id in the session matches the user id in the request. If not, the request is aborted.
|
||||
UserIdMatch(idParameter string) func(next http.Handler) http.Handler
|
||||
// InfoOnly only add user info to the request context. No login check is performed.
|
||||
InfoOnly() func(next http.Handler) http.Handler
|
||||
}
|
||||
|
||||
type Session interface {
|
||||
|
@@ -29,54 +29,12 @@ type AuthenticationService interface {
|
||||
OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error)
|
||||
}
|
||||
|
||||
type WebAuthnService interface {
|
||||
Enabled() bool
|
||||
StartWebAuthnRegistration(ctx context.Context, userId domain.UserIdentifier) (
|
||||
responseOptions []byte,
|
||||
sessionData []byte,
|
||||
err error,
|
||||
)
|
||||
FinishWebAuthnRegistration(
|
||||
ctx context.Context,
|
||||
userId domain.UserIdentifier,
|
||||
name string,
|
||||
sessionDataAsJSON []byte,
|
||||
r *http.Request,
|
||||
) ([]domain.UserWebauthnCredential, error)
|
||||
GetCredentials(
|
||||
ctx context.Context,
|
||||
userId domain.UserIdentifier,
|
||||
) ([]domain.UserWebauthnCredential, error)
|
||||
RemoveCredential(
|
||||
ctx context.Context,
|
||||
userId domain.UserIdentifier,
|
||||
credentialIdBase64 string,
|
||||
) ([]domain.UserWebauthnCredential, error)
|
||||
UpdateCredential(
|
||||
ctx context.Context,
|
||||
userId domain.UserIdentifier,
|
||||
credentialIdBase64 string,
|
||||
name string,
|
||||
) ([]domain.UserWebauthnCredential, error)
|
||||
StartWebAuthnLogin(_ context.Context) (
|
||||
optionsAsJSON []byte,
|
||||
sessionDataAsJSON []byte,
|
||||
err error,
|
||||
)
|
||||
FinishWebAuthnLogin(
|
||||
ctx context.Context,
|
||||
sessionDataAsJSON []byte,
|
||||
r *http.Request,
|
||||
) (*domain.User, error)
|
||||
}
|
||||
|
||||
type AuthEndpoint struct {
|
||||
cfg *config.Config
|
||||
authService AuthenticationService
|
||||
authenticator Authenticator
|
||||
session Session
|
||||
validate Validator
|
||||
webAuthn WebAuthnService
|
||||
}
|
||||
|
||||
func NewAuthEndpoint(
|
||||
@@ -85,7 +43,6 @@ func NewAuthEndpoint(
|
||||
session Session,
|
||||
validator Validator,
|
||||
authService AuthenticationService,
|
||||
webAuthn WebAuthnService,
|
||||
) AuthEndpoint {
|
||||
return AuthEndpoint{
|
||||
cfg: cfg,
|
||||
@@ -93,7 +50,6 @@ func NewAuthEndpoint(
|
||||
authenticator: authenticator,
|
||||
session: session,
|
||||
validate: validator,
|
||||
webAuthn: webAuthn,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,19 +66,6 @@ func (e AuthEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
||||
apiGroup.HandleFunc("GET /login/{provider}/init", e.handleOauthInitiateGet())
|
||||
apiGroup.HandleFunc("GET /login/{provider}/callback", e.handleOauthCallbackGet())
|
||||
|
||||
apiGroup.HandleFunc("POST /webauthn/login/start", e.handleWebAuthnLoginStart())
|
||||
apiGroup.HandleFunc("POST /webauthn/login/finish", e.handleWebAuthnLoginFinish())
|
||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("GET /webauthn/credentials",
|
||||
e.handleWebAuthnCredentialsGet())
|
||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /webauthn/register/start",
|
||||
e.handleWebAuthnRegisterStart())
|
||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /webauthn/register/finish",
|
||||
e.handleWebAuthnRegisterFinish())
|
||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("DELETE /webauthn/credential/{id}",
|
||||
e.handleWebAuthnCredentialsDelete())
|
||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("PUT /webauthn/credential/{id}",
|
||||
e.handleWebAuthnCredentialsPut())
|
||||
|
||||
apiGroup.HandleFunc("POST /login", e.handleLoginPost())
|
||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /logout", e.handleLogoutPost())
|
||||
}
|
||||
@@ -455,237 +398,3 @@ func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// handleWebAuthnCredentialsGet returns a gorm Handler function.
|
||||
//
|
||||
// @ID auth_handleWebAuthnCredentialsGet
|
||||
// @Tags Authentication
|
||||
// @Summary Get all available external login providers.
|
||||
// @Produce json
|
||||
// @Success 200 {object} []model.WebAuthnCredentialResponse
|
||||
// @Router /auth/webauthn/credentials [get]
|
||||
func (e AuthEndpoint) handleWebAuthnCredentialsGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.webAuthn.Enabled() {
|
||||
respond.JSON(w, http.StatusOK, []model.WebAuthnCredentialResponse{})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
|
||||
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
|
||||
|
||||
credentials, err := e.webAuthn.GetCredentials(r.Context(), userIdentifier)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebAuthnCredentialsDelete returns a gorm Handler function.
|
||||
//
|
||||
// @ID auth_handleWebAuthnCredentialsDelete
|
||||
// @Tags Authentication
|
||||
// @Summary Delete a WebAuthn credential.
|
||||
// @Param id path string true "Base64 encoded Credential ID"
|
||||
// @Produce json
|
||||
// @Success 200 {object} []model.WebAuthnCredentialResponse
|
||||
// @Router /auth/webauthn/credential/{id} [delete]
|
||||
func (e AuthEndpoint) handleWebAuthnCredentialsDelete() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.webAuthn.Enabled() {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
|
||||
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
|
||||
|
||||
credentialId := Base64UrlDecode(request.Path(r, "id"))
|
||||
|
||||
credentials, err := e.webAuthn.RemoveCredential(r.Context(), userIdentifier, credentialId)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebAuthnCredentialsPut returns a gorm Handler function.
|
||||
//
|
||||
// @ID auth_handleWebAuthnCredentialsPut
|
||||
// @Tags Authentication
|
||||
// @Summary Update a WebAuthn credential.
|
||||
// @Param id path string true "Base64 encoded Credential ID"
|
||||
// @Param request body model.WebAuthnCredentialRequest true "Credential name"
|
||||
// @Produce json
|
||||
// @Success 200 {object} []model.WebAuthnCredentialResponse
|
||||
// @Router /auth/webauthn/credential/{id} [put]
|
||||
func (e AuthEndpoint) handleWebAuthnCredentialsPut() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.webAuthn.Enabled() {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
|
||||
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
|
||||
|
||||
credentialId := Base64UrlDecode(request.Path(r, "id"))
|
||||
var req model.WebAuthnCredentialRequest
|
||||
if err := request.BodyJson(r, &req); err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
credentials, err := e.webAuthn.UpdateCredential(r.Context(), userIdentifier, credentialId, req.Name)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
|
||||
}
|
||||
}
|
||||
|
||||
func (e AuthEndpoint) handleWebAuthnRegisterStart() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.webAuthn.Enabled() {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
|
||||
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
|
||||
|
||||
options, sessionData, err := e.webAuthn.StartWebAuthnRegistration(r.Context(), userIdentifier)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession.WebAuthnData = string(sessionData)
|
||||
e.session.SetData(r.Context(), currentSession)
|
||||
|
||||
respond.Data(w, http.StatusOK, "application/json", options)
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebAuthnRegisterFinish returns a gorm Handler function.
|
||||
//
|
||||
// @ID auth_handleWebAuthnRegisterFinish
|
||||
// @Tags Authentication
|
||||
// @Summary Finish the WebAuthn registration process.
|
||||
// @Param credential_name query string false "Credential name" default("")
|
||||
// @Produce json
|
||||
// @Success 200 {object} []model.WebAuthnCredentialResponse
|
||||
// @Router /auth/webauthn/register/finish [post]
|
||||
func (e AuthEndpoint) handleWebAuthnRegisterFinish() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.webAuthn.Enabled() {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
name := request.QueryDefault(r, "credential_name", "")
|
||||
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
|
||||
webAuthnSessionData := []byte(currentSession.WebAuthnData)
|
||||
currentSession.WebAuthnData = "" // clear the session data
|
||||
e.session.SetData(r.Context(), currentSession)
|
||||
|
||||
credentials, err := e.webAuthn.FinishWebAuthnRegistration(
|
||||
r.Context(),
|
||||
domain.UserIdentifier(currentSession.UserIdentifier),
|
||||
name,
|
||||
webAuthnSessionData,
|
||||
r)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
|
||||
}
|
||||
}
|
||||
|
||||
func (e AuthEndpoint) handleWebAuthnLoginStart() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.webAuthn.Enabled() {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
|
||||
options, sessionData, err := e.webAuthn.StartWebAuthnLogin(r.Context())
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession.WebAuthnData = string(sessionData)
|
||||
e.session.SetData(r.Context(), currentSession)
|
||||
|
||||
respond.Data(w, http.StatusOK, "application/json", options)
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebAuthnLoginFinish returns a gorm Handler function.
|
||||
//
|
||||
// @ID auth_handleWebAuthnLoginFinish
|
||||
// @Tags Authentication
|
||||
// @Summary Finish the WebAuthn login process.
|
||||
// @Produce json
|
||||
// @Success 200 {object} model.User
|
||||
// @Router /auth/webauthn/login/finish [post]
|
||||
func (e AuthEndpoint) handleWebAuthnLoginFinish() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.webAuthn.Enabled() {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
|
||||
webAuthnSessionData := []byte(currentSession.WebAuthnData)
|
||||
currentSession.WebAuthnData = "" // clear the session data
|
||||
e.session.SetData(r.Context(), currentSession)
|
||||
|
||||
user, err := e.webAuthn.FinishWebAuthnLogin(
|
||||
r.Context(),
|
||||
webAuthnSessionData,
|
||||
r)
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusBadRequest,
|
||||
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
e.setAuthenticatedUser(r, user)
|
||||
|
||||
respond.JSON(w, http.StatusOK, model.NewUser(user, false))
|
||||
}
|
||||
}
|
||||
|
@@ -15,29 +15,22 @@ import (
|
||||
"github.com/h44z/wg-portal/internal/app/api/core/respond"
|
||||
"github.com/h44z/wg-portal/internal/app/api/v0/model"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
//go:embed frontend_config.js.gotpl
|
||||
var frontendJs embed.FS
|
||||
|
||||
type ControllerManager interface {
|
||||
GetControllerNames() []config.BackendBase
|
||||
}
|
||||
|
||||
type ConfigEndpoint struct {
|
||||
cfg *config.Config
|
||||
authenticator Authenticator
|
||||
controllerMgr ControllerManager
|
||||
|
||||
tpl *respond.TemplateRenderer
|
||||
}
|
||||
|
||||
func NewConfigEndpoint(cfg *config.Config, authenticator Authenticator, ctrlMgr ControllerManager) ConfigEndpoint {
|
||||
func NewConfigEndpoint(cfg *config.Config, authenticator Authenticator) ConfigEndpoint {
|
||||
ep := ConfigEndpoint{
|
||||
cfg: cfg,
|
||||
authenticator: authenticator,
|
||||
controllerMgr: ctrlMgr,
|
||||
tpl: respond.NewTemplateRenderer(template.Must(template.ParseFS(frontendJs,
|
||||
"frontend_config.js.gotpl"))),
|
||||
}
|
||||
@@ -53,7 +46,7 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
||||
apiGroup := g.Mount("/config")
|
||||
|
||||
apiGroup.HandleFunc("GET /frontend.js", e.handleConfigJsGet())
|
||||
apiGroup.With(e.authenticator.InfoOnly()).HandleFunc("GET /settings", e.handleSettingsGet())
|
||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("GET /settings", e.handleSettingsGet())
|
||||
}
|
||||
|
||||
// handleConfigJsGet returns a gorm Handler function.
|
||||
@@ -100,50 +93,11 @@ func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
|
||||
// @Router /config/settings [get]
|
||||
func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sessionUser := domain.GetUserInfo(r.Context())
|
||||
|
||||
controllerFn := func() []model.SettingsBackendNames {
|
||||
controllers := e.controllerMgr.GetControllerNames()
|
||||
names := make([]model.SettingsBackendNames, 0, len(controllers))
|
||||
|
||||
for _, controller := range controllers {
|
||||
displayName := controller.GetDisplayName()
|
||||
if displayName == "" {
|
||||
displayName = controller.Id // fallback to ID if no display name is set
|
||||
}
|
||||
if controller.Id == config.LocalBackendName {
|
||||
displayName = "modals.interface-edit.backend.local" // use a localized string for the local backend
|
||||
}
|
||||
names = append(names, model.SettingsBackendNames{
|
||||
Id: controller.Id,
|
||||
Name: displayName,
|
||||
})
|
||||
}
|
||||
|
||||
return names
|
||||
|
||||
}
|
||||
|
||||
hasSocialLogin := len(e.cfg.Auth.OAuth) > 0 || len(e.cfg.Auth.OpenIDConnect) > 0 || e.cfg.Auth.WebAuthn.Enabled
|
||||
|
||||
// For anonymous users, we return the settings object with minimal information
|
||||
if sessionUser.Id == domain.CtxUnknownUserId || sessionUser.Id == "" {
|
||||
respond.JSON(w, http.StatusOK, model.Settings{
|
||||
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
|
||||
AvailableBackends: []model.SettingsBackendNames{}, // return an empty list instead of null
|
||||
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
|
||||
})
|
||||
} else {
|
||||
respond.JSON(w, http.StatusOK, model.Settings{
|
||||
MailLinkOnly: e.cfg.Mail.LinkOnly,
|
||||
PersistentConfigSupported: e.cfg.Advanced.ConfigStoragePath != "",
|
||||
SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed,
|
||||
ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly,
|
||||
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
|
||||
MinPasswordLength: e.cfg.Auth.MinPasswordLength,
|
||||
AvailableBackends: controllerFn(),
|
||||
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -34,11 +34,11 @@ type PeerService interface {
|
||||
// DeletePeer deletes the peer with the given id.
|
||||
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
|
||||
// GetPeerConfig returns the peer configuration for the given id.
|
||||
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
|
||||
// GetPeerConfigQrCode returns the peer configuration as qr code for the given id.
|
||||
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
|
||||
// SendPeerEmail sends the peer configuration via email.
|
||||
SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error
|
||||
SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error
|
||||
// GetPeerStats returns the peer stats for the given interface.
|
||||
GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error)
|
||||
}
|
||||
@@ -355,7 +355,6 @@ func (e PeerEndpoint) handleDelete() http.HandlerFunc {
|
||||
// @Summary Get peer configuration as string.
|
||||
// @Produce json
|
||||
// @Param id path string true "The peer identifier"
|
||||
// @Param style query string false "The configuration style"
|
||||
// @Success 200 {object} string
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
@@ -370,9 +369,7 @@ func (e PeerEndpoint) handleConfigGet() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
configStyle := e.getConfigStyle(r)
|
||||
|
||||
configTxt, err := e.peerService.GetPeerConfig(r.Context(), domain.PeerIdentifier(id), configStyle)
|
||||
configTxt, err := e.peerService.GetPeerConfig(r.Context(), domain.PeerIdentifier(id))
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError, model.Error{
|
||||
Code: http.StatusInternalServerError, Message: err.Error(),
|
||||
@@ -400,7 +397,6 @@ func (e PeerEndpoint) handleConfigGet() http.HandlerFunc {
|
||||
// @Produce png
|
||||
// @Produce json
|
||||
// @Param id path string true "The peer identifier"
|
||||
// @Param style query string false "The configuration style"
|
||||
// @Success 200 {file} binary
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
@@ -415,9 +411,7 @@ func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
configStyle := e.getConfigStyle(r)
|
||||
|
||||
configQr, err := e.peerService.GetPeerConfigQrCode(r.Context(), domain.PeerIdentifier(id), configStyle)
|
||||
configQr, err := e.peerService.GetPeerConfigQrCode(r.Context(), domain.PeerIdentifier(id))
|
||||
if err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError, model.Error{
|
||||
Code: http.StatusInternalServerError, Message: err.Error(),
|
||||
@@ -444,7 +438,6 @@ func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc {
|
||||
// @Summary Send peer configuration via email.
|
||||
// @Produce json
|
||||
// @Param request body model.PeerMailRequest true "The peer mail request data"
|
||||
// @Param style query string false "The configuration style"
|
||||
// @Success 204 "No content if mail sending was successful"
|
||||
// @Failure 400 {object} model.Error
|
||||
// @Failure 500 {object} model.Error
|
||||
@@ -467,13 +460,11 @@ func (e PeerEndpoint) handleEmailPost() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
configStyle := e.getConfigStyle(r)
|
||||
|
||||
peerIds := make([]domain.PeerIdentifier, len(req.Identifiers))
|
||||
for i := range req.Identifiers {
|
||||
peerIds[i] = domain.PeerIdentifier(req.Identifiers[i])
|
||||
}
|
||||
if err := e.peerService.SendPeerEmail(r.Context(), req.LinkOnly, configStyle, peerIds...); err != nil {
|
||||
if err := e.peerService.SendPeerEmail(r.Context(), req.LinkOnly, peerIds...); err != nil {
|
||||
respond.JSON(w, http.StatusInternalServerError,
|
||||
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
|
||||
return
|
||||
@@ -513,11 +504,3 @@ func (e PeerEndpoint) handleStatsGet() http.HandlerFunc {
|
||||
respond.JSON(w, http.StatusOK, model.NewPeerStats(e.cfg.Statistics.CollectPeerData, stats))
|
||||
}
|
||||
}
|
||||
|
||||
func (e PeerEndpoint) getConfigStyle(r *http.Request) string {
|
||||
configStyle := request.QueryDefault(r, "style", domain.ConfigStyleWgQuick)
|
||||
if configStyle != domain.ConfigStyleWgQuick && configStyle != domain.ConfigStyleRaw {
|
||||
configStyle = domain.ConfigStyleWgQuick // default to wg-quick style
|
||||
}
|
||||
return configStyle
|
||||
}
|
||||
|
@@ -72,32 +72,6 @@ func (h AuthenticationHandler) LoggedIn(scopes ...Scope) func(next http.Handler)
|
||||
}
|
||||
}
|
||||
|
||||
// InfoOnly only checks if the user is logged in and adds the user id to the context.
|
||||
// If the user is not logged in, the context user id is set to domain.CtxUnknownUserId.
|
||||
func (h AuthenticationHandler) InfoOnly() func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := h.session.GetData(r.Context())
|
||||
|
||||
var newContext context.Context
|
||||
|
||||
if !session.LoggedIn {
|
||||
newContext = domain.SetUserInfo(r.Context(), domain.DefaultContextUserInfo())
|
||||
} else {
|
||||
newContext = domain.SetUserInfo(r.Context(), &domain.ContextUserInfo{
|
||||
Id: domain.UserIdentifier(session.UserIdentifier),
|
||||
IsAdmin: session.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
r = r.WithContext(newContext)
|
||||
|
||||
// Continue down the chain to Handler etc
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// UserIdMatch checks if the user id in the session matches the user id in the request. If not, the request is aborted.
|
||||
func (h AuthenticationHandler) UserIdMatch(idParameter string) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
|
@@ -31,8 +31,6 @@ type SessionData struct {
|
||||
OauthProvider string
|
||||
OauthReturnTo string
|
||||
|
||||
WebAuthnData string
|
||||
|
||||
CsrfToken string
|
||||
}
|
||||
|
||||
|
@@ -10,13 +10,4 @@ type Settings struct {
|
||||
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
|
||||
SelfProvisioning bool `json:"SelfProvisioning"`
|
||||
ApiAdminOnly bool `json:"ApiAdminOnly"`
|
||||
WebAuthnEnabled bool `json:"WebAuthnEnabled"`
|
||||
MinPasswordLength int `json:"MinPasswordLength"`
|
||||
AvailableBackends []SettingsBackendNames `json:"AvailableBackends"`
|
||||
LoginFormVisible bool `json:"LoginFormVisible"`
|
||||
}
|
||||
|
||||
type SettingsBackendNames struct {
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
@@ -1,11 +1,6 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
import "github.com/h44z/wg-portal/internal/domain"
|
||||
|
||||
type LoginProviderInfo struct {
|
||||
Identifier string `json:"Identifier" example:"google"`
|
||||
@@ -44,32 +39,3 @@ type OauthInitiationResponse struct {
|
||||
RedirectUrl string
|
||||
State string
|
||||
}
|
||||
|
||||
type WebAuthnCredentialRequest struct {
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
type WebAuthnCredentialResponse struct {
|
||||
ID string `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
CreatedAt string `json:"CreatedAt"`
|
||||
}
|
||||
|
||||
func NewWebAuthnCredentialResponse(src domain.UserWebauthnCredential) WebAuthnCredentialResponse {
|
||||
return WebAuthnCredentialResponse{
|
||||
ID: src.CredentialIdentifier,
|
||||
Name: src.DisplayName,
|
||||
CreatedAt: src.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
func NewWebAuthnCredentialResponses(src []domain.UserWebauthnCredential) []WebAuthnCredentialResponse {
|
||||
credentials := make([]WebAuthnCredentialResponse, len(src))
|
||||
for i := range src {
|
||||
credentials[i] = NewWebAuthnCredentialResponse(src[i])
|
||||
}
|
||||
// Sort by CreatedAt, newest first
|
||||
slices.SortFunc(credentials, func(i, j WebAuthnCredentialResponse) int {
|
||||
return strings.Compare(i.CreatedAt, j.CreatedAt)
|
||||
})
|
||||
return credentials
|
||||
}
|
||||
|
@@ -4,7 +4,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
@@ -12,7 +11,6 @@ type Interface struct {
|
||||
Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0
|
||||
DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface
|
||||
Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any'
|
||||
Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ...
|
||||
PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface
|
||||
PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
|
||||
Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
|
||||
@@ -59,7 +57,6 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
|
||||
Identifier: string(src.Identifier),
|
||||
DisplayName: src.DisplayName,
|
||||
Mode: string(src.Type),
|
||||
Backend: string(src.Backend),
|
||||
PrivateKey: src.PrivateKey,
|
||||
PublicKey: src.PublicKey,
|
||||
Disabled: src.IsDisabled(),
|
||||
@@ -95,10 +92,6 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
|
||||
Filename: src.GetConfigFileName(),
|
||||
}
|
||||
|
||||
if iface.Backend == "" {
|
||||
iface.Backend = config.LocalBackendName // default to local backend
|
||||
}
|
||||
|
||||
if len(peers) > 0 {
|
||||
iface.TotalPeers = len(peers)
|
||||
|
||||
@@ -153,7 +146,6 @@ func NewDomainInterface(src *Interface) *domain.Interface {
|
||||
SaveConfig: src.SaveConfig,
|
||||
DisplayName: src.DisplayName,
|
||||
Type: domain.InterfaceType(src.Mode),
|
||||
Backend: domain.InterfaceBackend(src.Backend),
|
||||
DriverType: "", // currently unused
|
||||
Disabled: nil, // set below
|
||||
DisabledReason: src.DisabledReason,
|
||||
|
@@ -198,7 +198,7 @@ func NewPeerStats(enabled bool, src []domain.PeerStatus) *PeerStats {
|
||||
|
||||
for _, srcStat := range src {
|
||||
stats[string(srcStat.PeerId)] = PeerStatData{
|
||||
IsConnected: srcStat.IsConnected,
|
||||
IsConnected: srcStat.IsConnected(),
|
||||
IsPingable: srcStat.IsPingable,
|
||||
LastPing: srcStat.LastPing,
|
||||
BytesReceived: srcStat.BytesReceived,
|
||||
|
@@ -23,8 +23,8 @@ type ProvisioningServicePeerManagerRepo interface {
|
||||
}
|
||||
|
||||
type ProvisioningServiceConfigFileManagerRepo interface {
|
||||
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
|
||||
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
|
||||
}
|
||||
|
||||
type ProvisioningService struct {
|
||||
@@ -96,7 +96,7 @@ func (p ProvisioningService) GetPeerConfig(ctx context.Context, peerId domain.Pe
|
||||
return nil, err
|
||||
}
|
||||
|
||||
peerCfgReader, err := p.configFiles.GetPeerConfig(ctx, peer.Identifier, domain.ConfigStyleWgQuick)
|
||||
peerCfgReader, err := p.configFiles.GetPeerConfig(ctx, peer.Identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func (p ProvisioningService) GetPeerQrPng(ctx context.Context, peerId domain.Pee
|
||||
return nil, err
|
||||
}
|
||||
|
||||
peerCfgQrReader, err := p.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier, domain.ConfigStyleWgQuick)
|
||||
peerCfgQrReader, err := p.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -162,11 +162,7 @@ func (p ProvisioningService) NewPeer(ctx context.Context, req models.Provisionin
|
||||
if req.PresharedKey != "" {
|
||||
peer.PresharedKey = domain.PreSharedKey(req.PresharedKey)
|
||||
}
|
||||
if req.DisplayName == "" {
|
||||
peer.GenerateDisplayName("API")
|
||||
} else {
|
||||
peer.DisplayName = req.DisplayName
|
||||
}
|
||||
|
||||
// save new peer
|
||||
peer, err = p.peers.CreatePeer(ctx, peer)
|
||||
|
@@ -68,10 +68,6 @@ type ProvisioningRequest struct {
|
||||
// If no user identifier is set, the authenticated user is used.
|
||||
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 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.
|
||||
|
@@ -46,7 +46,7 @@ func Initialize(
|
||||
users: users,
|
||||
}
|
||||
|
||||
startupContext, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
startupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Switch to admin user context
|
||||
|
@@ -93,8 +93,6 @@ type Authenticator struct {
|
||||
// URL prefix for the callback endpoints, this is a combination of the external URL and the API prefix
|
||||
callbackUrlPrefix string
|
||||
|
||||
callbackUrl *url.URL
|
||||
|
||||
users UserManager
|
||||
}
|
||||
|
||||
@@ -108,132 +106,78 @@ func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserM
|
||||
bus: bus,
|
||||
users: users,
|
||||
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)),
|
||||
}
|
||||
|
||||
parsedExtUrl, err := url.Parse(a.callbackUrlPrefix)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := a.setupExternalAuthProviders(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse external URL: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
a.callbackUrl = parsedExtUrl
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// StartBackgroundJobs starts the background jobs for the authenticator.
|
||||
// It sets up the external authentication providers (OIDC, OAuth, LDAP) and retries in case of errors.
|
||||
func (a *Authenticator) StartBackgroundJobs(ctx context.Context) {
|
||||
go func() {
|
||||
// Initialize local copies of authentication providers to allow retry in case of errors
|
||||
oidcQueue := a.cfg.OpenIDConnect
|
||||
oauthQueue := a.cfg.OAuth
|
||||
ldapQueue := a.cfg.Ldap
|
||||
|
||||
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(ctx context.Context) error {
|
||||
extUrl, err := url.Parse(a.callbackUrlPrefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse external url: %w", err)
|
||||
}
|
||||
|
||||
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()
|
||||
a.oauthAuthenticators = make(map[string]AuthenticatorOauth, len(a.cfg.OpenIDConnect)+len(a.cfg.OAuth))
|
||||
a.ldapAuthenticators = make(map[string]AuthenticatorLdap, len(a.cfg.Ldap))
|
||||
|
||||
var failedOidc []config.OpenIDConnectProvider
|
||||
var failedOauth []config.OAuthProvider
|
||||
var failedLdap []config.LdapProvider
|
||||
|
||||
for i := range oidc { // OIDC
|
||||
providerCfg := &oidc[i]
|
||||
for i := range a.cfg.OpenIDConnect { // OIDC
|
||||
providerCfg := &a.cfg.OpenIDConnect[i]
|
||||
providerId := strings.ToLower(providerCfg.ProviderName)
|
||||
|
||||
if _, exists := a.oauthAuthenticators[providerId]; exists {
|
||||
// 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
|
||||
return fmt.Errorf("auth provider with name %s is already registerd", providerId)
|
||||
}
|
||||
|
||||
redirectUrl := *a.callbackUrl
|
||||
redirectUrl := *extUrl
|
||||
redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback")
|
||||
|
||||
provider, err := newOidcAuthenticator(ctx, redirectUrl.String(), providerCfg)
|
||||
if err != nil {
|
||||
failedOidc = append(failedOidc, oidc[i])
|
||||
slog.Error("failed to setup oidc authentication provider", "name", providerId, "error", err)
|
||||
continue
|
||||
return fmt.Errorf("failed to setup oidc authentication provider %s: %w", providerCfg.ProviderName, err)
|
||||
}
|
||||
a.oauthAuthenticators[providerId] = provider
|
||||
}
|
||||
for i := range oauth { // PLAIN OAUTH
|
||||
providerCfg := &oauth[i]
|
||||
for i := range a.cfg.OAuth { // PLAIN OAUTH
|
||||
providerCfg := &a.cfg.OAuth[i]
|
||||
providerId := strings.ToLower(providerCfg.ProviderName)
|
||||
|
||||
if _, exists := a.oauthAuthenticators[providerId]; exists {
|
||||
// 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
|
||||
return fmt.Errorf("auth provider with name %s is already registerd", providerId)
|
||||
}
|
||||
|
||||
redirectUrl := *a.callbackUrl
|
||||
redirectUrl := *extUrl
|
||||
redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback")
|
||||
|
||||
provider, err := newPlainOauthAuthenticator(ctx, redirectUrl.String(), providerCfg)
|
||||
if err != nil {
|
||||
failedOauth = append(failedOauth, oauth[i])
|
||||
slog.Error("failed to setup oauth authentication provider", "name", providerId, "error", err)
|
||||
continue
|
||||
return fmt.Errorf("failed to setup oauth authentication provider %s: %w", providerId, err)
|
||||
}
|
||||
a.oauthAuthenticators[providerId] = provider
|
||||
}
|
||||
for i := range ldap { // LDAP
|
||||
providerCfg := &ldap[i]
|
||||
for i := range a.cfg.Ldap { // LDAP
|
||||
providerCfg := &a.cfg.Ldap[i]
|
||||
providerId := strings.ToLower(providerCfg.URL)
|
||||
|
||||
if _, exists := a.ldapAuthenticators[providerId]; exists {
|
||||
// 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
|
||||
return fmt.Errorf("auth provider with name %s is already registerd", providerId)
|
||||
}
|
||||
|
||||
provider, err := newLdapAuthenticator(ctx, providerCfg)
|
||||
if err != nil {
|
||||
failedLdap = append(failedLdap, ldap[i])
|
||||
slog.Error("failed to setup ldap authentication provider", "name", providerId, "error", err)
|
||||
continue
|
||||
return fmt.Errorf("failed to setup ldap authentication provider %s: %w", providerId, err)
|
||||
}
|
||||
a.ldapAuthenticators[providerId] = provider
|
||||
}
|
||||
|
||||
return failedOidc, failedOauth, failedLdap
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetExternalLoginProviders returns a list of all available external login providers.
|
||||
@@ -490,10 +434,6 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
|
||||
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,
|
||||
domain.SystemAdminContextUserInfo()) // switch to admin user context to check if user exists
|
||||
user, err := a.processUserInfo(ctx, userInfo, domain.UserSourceOauth, oauthProvider.GetName(),
|
||||
@@ -510,6 +450,10 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
|
||||
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() {
|
||||
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
|
||||
Ctx: ctx,
|
||||
|
@@ -1,301 +0,0 @@
|
||||
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(iface *domain.Interface, peers []domain.Peer) (io.Reader, error)
|
||||
// GetPeerConfig returns the configuration file for the given peer.
|
||||
GetPeerConfig(peer *domain.Peer, style string) (io.Reader, error)
|
||||
GetPeerConfig(peer *domain.Peer) (io.Reader, error)
|
||||
}
|
||||
|
||||
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.
|
||||
// The file is structured in wg-quick format.
|
||||
func (m Manager) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) {
|
||||
func (m Manager) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) {
|
||||
peer, err := m.wg.GetPeer(ctx, id)
|
||||
if err != nil {
|
||||
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, st
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m.tplHandler.GetPeerConfig(peer, style)
|
||||
return m.tplHandler.GetPeerConfig(peer)
|
||||
}
|
||||
|
||||
// GetPeerConfigQrCode returns a QR code image containing the configuration for the given peer.
|
||||
func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) {
|
||||
func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) {
|
||||
peer, err := m.wg.GetPeer(ctx, id)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
cfgData, err := m.tplHandler.GetPeerConfig(peer, style)
|
||||
cfgData, err := m.tplHandler.GetPeerConfig(peer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get peer config for %s: %w", id, err)
|
||||
}
|
||||
|
@@ -55,11 +55,10 @@ func (c TemplateHandler) GetInterfaceConfig(cfg *domain.Interface, peers []domai
|
||||
}
|
||||
|
||||
// GetPeerConfig returns the rendered configuration file for a WireGuard peer.
|
||||
func (c TemplateHandler) GetPeerConfig(peer *domain.Peer, style string) (io.Reader, error) {
|
||||
func (c TemplateHandler) GetPeerConfig(peer *domain.Peer) (io.Reader, error) {
|
||||
var tplBuff bytes.Buffer
|
||||
|
||||
err := c.templates.ExecuteTemplate(&tplBuff, "wg_peer.tpl", map[string]any{
|
||||
"Style": style,
|
||||
"Peer": peer,
|
||||
"Portal": map[string]any{
|
||||
"Version": "unknown",
|
||||
|
@@ -1,8 +1,6 @@
|
||||
# AUTOGENERATED FILE - DO NOT EDIT
|
||||
# This file uses {{ .Style }} format.
|
||||
{{- if eq .Style "wgquick"}}
|
||||
# This file uses wg-quick format.
|
||||
# See https://man7.org/linux/man-pages/man8/wg-quick.8.html#CONFIGURATION
|
||||
{{- end}}
|
||||
# Lines starting with the -WGP- tag are used by
|
||||
# the WireGuard Portal configuration parser.
|
||||
|
||||
@@ -23,27 +21,22 @@
|
||||
|
||||
# Core settings
|
||||
PrivateKey = {{ .Peer.Interface.KeyPair.PrivateKey }}
|
||||
{{- if eq .Style "wgquick"}}
|
||||
Address = {{ CidrsToString .Peer.Interface.Addresses }}
|
||||
{{- end}}
|
||||
|
||||
# Misc. settings (optional)
|
||||
{{- if eq .Style "wgquick"}}
|
||||
{{- if .Peer.Interface.DnsStr.GetValue}}
|
||||
DNS = {{ .Peer.Interface.DnsStr.GetValue }} {{- if .Peer.Interface.DnsSearchStr.GetValue}}, {{ .Peer.Interface.DnsSearchStr.GetValue }} {{- end}}
|
||||
{{- end}}
|
||||
{{- if ne .Peer.Interface.Mtu.GetValue 0}}
|
||||
MTU = {{ .Peer.Interface.Mtu.GetValue }}
|
||||
{{- end}}
|
||||
{{- if ne .Peer.Interface.RoutingTable.GetValue ""}}
|
||||
Table = {{ .Peer.Interface.RoutingTable.GetValue }}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- if ne .Peer.Interface.FirewallMark.GetValue 0}}
|
||||
FwMark = {{ .Peer.Interface.FirewallMark.GetValue }}
|
||||
{{- end}}
|
||||
{{- if ne .Peer.Interface.RoutingTable.GetValue ""}}
|
||||
Table = {{ .Peer.Interface.RoutingTable.GetValue }}
|
||||
{{- end}}
|
||||
|
||||
{{- if eq .Style "wgquick"}}
|
||||
# Interface hooks (optional)
|
||||
{{- if .Peer.Interface.PreUp.GetValue}}
|
||||
PreUp = {{ .Peer.Interface.PreUp.GetValue }}
|
||||
@@ -57,7 +50,6 @@ PreDown = {{ .Peer.Interface.PreDown.GetValue }}
|
||||
{{- if .Peer.Interface.PostDown.GetValue}}
|
||||
PostDown = {{ .Peer.Interface.PostDown.GetValue }}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
[Peer]
|
||||
PublicKey = {{ .Peer.EndpointPublicKey.GetValue }}
|
||||
|
@@ -36,7 +36,6 @@ const TopicPeerDeleted = "peer:deleted"
|
||||
const TopicPeerUpdated = "peer:updated"
|
||||
const TopicPeerInterfaceUpdated = "peer:interface:updated"
|
||||
const TopicPeerIdentifierUpdated = "peer:identifier:updated"
|
||||
const TopicPeerStateChanged = "peer:state:changed"
|
||||
|
||||
// endregion peer-events
|
||||
|
||||
|
@@ -21,9 +21,9 @@ type ConfigFileManager interface {
|
||||
// GetInterfaceConfig returns the configuration for the given interface.
|
||||
GetInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) (io.Reader, error)
|
||||
// GetPeerConfig returns the configuration for the given peer.
|
||||
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
|
||||
// GetPeerConfigQrCode returns the QR code for the given peer.
|
||||
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
|
||||
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
|
||||
}
|
||||
|
||||
type UserDatabaseRepo interface {
|
||||
@@ -71,7 +71,7 @@ func NewMailManager(
|
||||
users UserDatabaseRepo,
|
||||
wg WireguardDatabaseRepo,
|
||||
) (*Manager, error) {
|
||||
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle)
|
||||
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl)
|
||||
if err != nil {
|
||||
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.
|
||||
func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error {
|
||||
func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error {
|
||||
for _, peerId := range peers {
|
||||
peer, err := m.wg.GetPeer(ctx, peerId)
|
||||
if err != nil {
|
||||
@@ -123,7 +123,7 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, style string,
|
||||
continue
|
||||
}
|
||||
|
||||
err = m.sendPeerEmail(ctx, linkOnly, style, user, peer)
|
||||
err = m.sendPeerEmail(ctx, linkOnly, user, peer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send peer email for %s: %w", peerId, err)
|
||||
}
|
||||
@@ -132,13 +132,7 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, style string,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) sendPeerEmail(
|
||||
ctx context.Context,
|
||||
linkOnly bool,
|
||||
style string,
|
||||
user *domain.User,
|
||||
peer *domain.Peer,
|
||||
) error {
|
||||
func (m Manager) sendPeerEmail(ctx context.Context, linkOnly bool, user *domain.User, peer *domain.Peer) error {
|
||||
qrName := "WireGuardQRCode.png"
|
||||
configName := peer.GetConfigFileName()
|
||||
|
||||
@@ -154,12 +148,12 @@ func (m Manager) sendPeerEmail(
|
||||
}
|
||||
|
||||
} else {
|
||||
peerConfig, err := m.configFiles.GetPeerConfig(ctx, peer.Identifier, style)
|
||||
peerConfig, err := m.configFiles.GetPeerConfig(ctx, peer.Identifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch peer config for %s: %w", peer.Identifier, err)
|
||||
}
|
||||
|
||||
peerConfigQr, err := m.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier, style)
|
||||
peerConfigQr, err := m.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch peer config QR code for %s: %w", peer.Identifier, err)
|
||||
}
|
||||
|
@@ -17,12 +17,11 @@ var TemplateFiles embed.FS
|
||||
// TemplateHandler is a struct that holds the html and text templates.
|
||||
type TemplateHandler struct {
|
||||
portalUrl string
|
||||
portalName string
|
||||
htmlTemplates *htmlTemplate.Template
|
||||
textTemplates *template.Template
|
||||
}
|
||||
|
||||
func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error) {
|
||||
func newTemplateHandler(portalUrl string) (*TemplateHandler, error) {
|
||||
htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse html template files: %w", err)
|
||||
@@ -35,7 +34,6 @@ func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error)
|
||||
|
||||
handler := &TemplateHandler{
|
||||
portalUrl: portalUrl,
|
||||
portalName: portalName,
|
||||
htmlTemplates: htmlTemplateCache,
|
||||
textTemplates: txtTemplateCache,
|
||||
}
|
||||
@@ -83,7 +81,6 @@ func (c TemplateHandler) GetConfigMailWithAttachment(user *domain.User, cfgName,
|
||||
"ConfigFileName": cfgName,
|
||||
"QrcodePngName": qrName,
|
||||
"PortalUrl": c.portalUrl,
|
||||
"PortalName": c.portalName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gotpl: %w", err)
|
||||
@@ -94,7 +91,6 @@ func (c TemplateHandler) GetConfigMailWithAttachment(user *domain.User, cfgName,
|
||||
"ConfigFileName": cfgName,
|
||||
"QrcodePngName": qrName,
|
||||
"PortalUrl": c.portalUrl,
|
||||
"PortalName": c.portalName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gohtml: %w", err)
|
||||
|
@@ -19,7 +19,7 @@
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" />
|
||||
<!--<![endif]-->
|
||||
<title>{{$.PortalName}}</title>
|
||||
<title>Email Template</title>
|
||||
<!--[if gte mso 9]>
|
||||
<style type="text/css" media="all">
|
||||
sup { font-size: 100% !important; }
|
||||
@@ -143,7 +143,7 @@
|
||||
<td align="left">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
@@ -167,10 +167,10 @@
|
||||
<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">
|
||||
<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 by {{$.PortalName}}.</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 using WireGuard Portal.</td>
|
||||
</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 {{$.PortalName}}</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 WireGuard Portal</span></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
|
@@ -20,5 +20,5 @@ You can download and install the WireGuard VPN client from:
|
||||
https://www.wireguard.com/install/
|
||||
|
||||
|
||||
This mail was generated by {{$.PortalName}}.
|
||||
This mail was generated using WireGuard Portal.
|
||||
{{$.PortalUrl}}
|
@@ -19,7 +19,7 @@
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" />
|
||||
<!--<![endif]-->
|
||||
<title>{{$.PortalName}}</title>
|
||||
<title>Email Template</title>
|
||||
<!--[if gte mso 9]>
|
||||
<style type="text/css" media="all">
|
||||
sup { font-size: 100% !important; }
|
||||
@@ -143,7 +143,7 @@
|
||||
<td align="left">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
@@ -167,10 +167,10 @@
|
||||
<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">
|
||||
<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 by {{$.PortalName}}.</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 using WireGuard Portal.</td>
|
||||
</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 {{$.PortalName}}</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 WireGuard Portal</span></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
|
@@ -20,5 +20,5 @@ You can download and install the WireGuard VPN client from:
|
||||
https://www.wireguard.com/install/
|
||||
|
||||
|
||||
This mail was generated by {{$.PortalName}}.
|
||||
This mail was generated using WireGuard Portal.
|
||||
{{$.PortalUrl}}
|
@@ -25,8 +25,6 @@ type UserDatabaseRepo interface {
|
||||
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
||||
// GetUserByEmail returns the user with the given email address.
|
||||
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(ctx context.Context) ([]domain.User, error)
|
||||
// FindUsers returns all users matching the search string.
|
||||
@@ -131,25 +129,6 @@ func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User
|
||||
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.
|
||||
func (m Manager) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
||||
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
||||
@@ -364,10 +343,6 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Use
|
||||
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 {
|
||||
return fmt.Errorf("cannot remove own admin rights: %w", domain.ErrInvalidData)
|
||||
}
|
||||
@@ -422,11 +397,7 @@ func (m Manager) validateCreation(ctx context.Context, new *domain.User) error {
|
||||
|
||||
// database users must have a password
|
||||
if new.Source == domain.UserSourceDatabase && string(new.Password) == "" {
|
||||
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 fmt.Errorf("invalid password: %w", domain.ErrInvalidData)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@@ -8,7 +8,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"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/domain"
|
||||
)
|
||||
@@ -65,7 +64,6 @@ func (m Manager) connectToMessageBus() {
|
||||
_ = m.bus.Subscribe(app.TopicPeerCreated, m.handlePeerCreateEvent)
|
||||
_ = m.bus.Subscribe(app.TopicPeerUpdated, m.handlePeerUpdateEvent)
|
||||
_ = 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.TopicInterfaceUpdated, m.handleInterfaceUpdateEvent)
|
||||
@@ -102,47 +100,39 @@ func (m Manager) sendWebhook(ctx context.Context, data io.Reader) error {
|
||||
}
|
||||
|
||||
func (m Manager) handleUserCreateEvent(user domain.User) {
|
||||
m.handleGenericEvent(WebhookEventCreate, models.NewUser(user))
|
||||
m.handleGenericEvent(WebhookEventCreate, user)
|
||||
}
|
||||
|
||||
func (m Manager) handleUserUpdateEvent(user domain.User) {
|
||||
m.handleGenericEvent(WebhookEventUpdate, models.NewUser(user))
|
||||
m.handleGenericEvent(WebhookEventUpdate, user)
|
||||
}
|
||||
|
||||
func (m Manager) handleUserDeleteEvent(user domain.User) {
|
||||
m.handleGenericEvent(WebhookEventDelete, models.NewUser(user))
|
||||
m.handleGenericEvent(WebhookEventDelete, user)
|
||||
}
|
||||
|
||||
func (m Manager) handlePeerCreateEvent(peer domain.Peer) {
|
||||
m.handleGenericEvent(WebhookEventCreate, models.NewPeer(peer))
|
||||
m.handleGenericEvent(WebhookEventCreate, peer)
|
||||
}
|
||||
|
||||
func (m Manager) handlePeerUpdateEvent(peer domain.Peer) {
|
||||
m.handleGenericEvent(WebhookEventUpdate, models.NewPeer(peer))
|
||||
m.handleGenericEvent(WebhookEventUpdate, peer)
|
||||
}
|
||||
|
||||
func (m Manager) handlePeerDeleteEvent(peer domain.Peer) {
|
||||
m.handleGenericEvent(WebhookEventDelete, models.NewPeer(peer))
|
||||
m.handleGenericEvent(WebhookEventDelete, peer)
|
||||
}
|
||||
|
||||
func (m Manager) handleInterfaceCreateEvent(iface domain.Interface) {
|
||||
m.handleGenericEvent(WebhookEventCreate, models.NewInterface(iface))
|
||||
m.handleGenericEvent(WebhookEventCreate, iface)
|
||||
}
|
||||
|
||||
func (m Manager) handleInterfaceUpdateEvent(iface domain.Interface) {
|
||||
m.handleGenericEvent(WebhookEventUpdate, models.NewInterface(iface))
|
||||
m.handleGenericEvent(WebhookEventUpdate, iface)
|
||||
}
|
||||
|
||||
func (m Manager) handleInterfaceDeleteEvent(iface domain.Interface) {
|
||||
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))
|
||||
}
|
||||
m.handleGenericEvent(WebhookEventDelete, iface)
|
||||
}
|
||||
|
||||
func (m Manager) handleGenericEvent(action WebhookEvent, payload any) {
|
||||
@@ -178,18 +168,15 @@ func (m Manager) createWebhookData(action WebhookEvent, payload any) (*WebhookDa
|
||||
}
|
||||
|
||||
switch v := payload.(type) {
|
||||
case models.User:
|
||||
case domain.User:
|
||||
d.Entity = WebhookEntityUser
|
||||
d.Identifier = v.Identifier
|
||||
case models.Peer:
|
||||
d.Identifier = string(v.Identifier)
|
||||
case domain.Peer:
|
||||
d.Entity = WebhookEntityPeer
|
||||
d.Identifier = v.Identifier
|
||||
case models.Interface:
|
||||
d.Identifier = string(v.Identifier)
|
||||
case domain.Interface:
|
||||
d.Entity = WebhookEntityInterface
|
||||
d.Identifier = v.Identifier
|
||||
case models.PeerMetrics:
|
||||
d.Entity = WebhookEntityPeerMetric
|
||||
d.Identifier = v.Peer.Identifier
|
||||
d.Identifier = string(v.Identifier)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported payload type: %T", v)
|
||||
}
|
||||
|
@@ -36,7 +36,6 @@ type WebhookEntity = string
|
||||
const (
|
||||
WebhookEntityUser WebhookEntity = "user"
|
||||
WebhookEntityPeer WebhookEntity = "peer"
|
||||
WebhookEntityPeerMetric WebhookEntity = "peer_metric"
|
||||
WebhookEntityInterface WebhookEntity = "interface"
|
||||
)
|
||||
|
||||
@@ -46,6 +45,4 @@ const (
|
||||
WebhookEventCreate WebhookEvent = "create"
|
||||
WebhookEventUpdate WebhookEvent = "update"
|
||||
WebhookEventDelete WebhookEvent = "delete"
|
||||
WebhookEventConnect WebhookEvent = "connect"
|
||||
WebhookEventDisconnect WebhookEvent = "disconnect"
|
||||
)
|
||||
|
@@ -1,99 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
// Interface represents an interface model for webhooks. For details about the fields, see the domain.Interface struct.
|
||||
type Interface struct {
|
||||
CreatedBy string `json:"CreatedBy"`
|
||||
UpdatedBy string `json:"UpdatedBy"`
|
||||
CreatedAt time.Time `json:"CreatedAt"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt"`
|
||||
|
||||
Identifier string `json:"Identifier"`
|
||||
PrivateKey string `json:"PrivateKey"`
|
||||
PublicKey string `json:"PublicKey"`
|
||||
ListenPort int `json:"ListenPort"`
|
||||
|
||||
Addresses []string `json:"Addresses"`
|
||||
DnsStr string `json:"DnsStr"`
|
||||
DnsSearchStr string `json:"DnsSearchStr"`
|
||||
|
||||
Mtu int `json:"Mtu"`
|
||||
FirewallMark uint32 `json:"FirewallMark"`
|
||||
RoutingTable string `json:"RoutingTable"`
|
||||
|
||||
PreUp string `json:"PreUp"`
|
||||
PostUp string `json:"PostUp"`
|
||||
PreDown string `json:"PreDown"`
|
||||
PostDown string `json:"PostDown"`
|
||||
|
||||
SaveConfig bool `json:"SaveConfig"`
|
||||
|
||||
DisplayName string `json:"DisplayName"`
|
||||
Type string `json:"Type"`
|
||||
DriverType string `json:"DriverType"`
|
||||
Disabled *time.Time `json:"Disabled,omitempty"`
|
||||
DisabledReason string `json:"DisabledReason,omitempty"`
|
||||
|
||||
PeerDefNetworkStr string `json:"PeerDefNetworkStr,omitempty"`
|
||||
PeerDefDnsStr string `json:"PeerDefDnsStr,omitempty"`
|
||||
PeerDefDnsSearchStr string `json:"PeerDefDnsSearchStr,omitempty"`
|
||||
PeerDefEndpoint string `json:"PeerDefEndpoint,omitempty"`
|
||||
PeerDefAllowedIPsStr string `json:"PeerDefAllowedIPsStr,omitempty"`
|
||||
PeerDefMtu int `json:"PeerDefMtu,omitempty"`
|
||||
PeerDefPersistentKeepalive int `json:"PeerDefPersistentKeepalive,omitempty"`
|
||||
PeerDefFirewallMark uint32 `json:"PeerDefFirewallMark,omitempty"`
|
||||
PeerDefRoutingTable string `json:"PeerDefRoutingTable,omitempty"`
|
||||
|
||||
PeerDefPreUp string `json:"PeerDefPreUp,omitempty"`
|
||||
PeerDefPostUp string `json:"PeerDefPostUp,omitempty"`
|
||||
PeerDefPreDown string `json:"PeerDefPreDown,omitempty"`
|
||||
PeerDefPostDown string `json:"PeerDefPostDown,omitempty"`
|
||||
}
|
||||
|
||||
// NewInterface creates a new Interface model from a domain.Interface.
|
||||
func NewInterface(src domain.Interface) Interface {
|
||||
return Interface{
|
||||
CreatedBy: src.CreatedBy,
|
||||
UpdatedBy: src.UpdatedBy,
|
||||
CreatedAt: src.CreatedAt,
|
||||
UpdatedAt: src.UpdatedAt,
|
||||
Identifier: string(src.Identifier),
|
||||
PrivateKey: src.KeyPair.PrivateKey,
|
||||
PublicKey: src.KeyPair.PublicKey,
|
||||
ListenPort: src.ListenPort,
|
||||
Addresses: domain.CidrsToStringSlice(src.Addresses),
|
||||
DnsStr: src.DnsStr,
|
||||
DnsSearchStr: src.DnsSearchStr,
|
||||
Mtu: src.Mtu,
|
||||
FirewallMark: src.FirewallMark,
|
||||
RoutingTable: src.RoutingTable,
|
||||
PreUp: src.PreUp,
|
||||
PostUp: src.PostUp,
|
||||
PreDown: src.PreDown,
|
||||
PostDown: src.PostDown,
|
||||
SaveConfig: src.SaveConfig,
|
||||
DisplayName: string(src.Identifier),
|
||||
Type: string(src.Type),
|
||||
DriverType: src.DriverType,
|
||||
Disabled: src.Disabled,
|
||||
DisabledReason: src.DisabledReason,
|
||||
PeerDefNetworkStr: src.PeerDefNetworkStr,
|
||||
PeerDefDnsStr: src.PeerDefDnsStr,
|
||||
PeerDefDnsSearchStr: src.PeerDefDnsSearchStr,
|
||||
PeerDefEndpoint: src.PeerDefEndpoint,
|
||||
PeerDefAllowedIPsStr: src.PeerDefAllowedIPsStr,
|
||||
PeerDefMtu: src.PeerDefMtu,
|
||||
PeerDefPersistentKeepalive: src.PeerDefPersistentKeepalive,
|
||||
PeerDefFirewallMark: src.PeerDefFirewallMark,
|
||||
PeerDefRoutingTable: src.PeerDefRoutingTable,
|
||||
PeerDefPreUp: src.PeerDefPreUp,
|
||||
PeerDefPostUp: src.PeerDefPostUp,
|
||||
PeerDefPreDown: src.PeerDefPreDown,
|
||||
PeerDefPostDown: src.PeerDefPostDown,
|
||||
}
|
||||
}
|
@@ -1,89 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
// Peer represents a peer model for webhooks. For details about the fields, see the domain.Peer struct.
|
||||
type Peer struct {
|
||||
CreatedBy string `json:"CreatedBy"`
|
||||
UpdatedBy string `json:"UpdatedBy"`
|
||||
CreatedAt time.Time `json:"CreatedAt"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt"`
|
||||
|
||||
Endpoint string `json:"Endpoint"`
|
||||
EndpointPublicKey string `json:"EndpointPublicKey"`
|
||||
AllowedIPsStr string `json:"AllowedIPsStr"`
|
||||
ExtraAllowedIPsStr string `json:"ExtraAllowedIPsStr"`
|
||||
PresharedKey string `json:"PresharedKey"`
|
||||
PersistentKeepalive int `json:"PersistentKeepalive"`
|
||||
|
||||
DisplayName string `json:"DisplayName"`
|
||||
Identifier string `json:"Identifier"`
|
||||
UserIdentifier string `json:"UserIdentifier"`
|
||||
InterfaceIdentifier string `json:"InterfaceIdentifier"`
|
||||
Disabled *time.Time `json:"Disabled,omitempty"`
|
||||
DisabledReason string `json:"DisabledReason,omitempty"`
|
||||
ExpiresAt *time.Time `json:"ExpiresAt,omitempty"`
|
||||
Notes string `json:"Notes,omitempty"`
|
||||
AutomaticallyCreated bool `json:"AutomaticallyCreated"`
|
||||
|
||||
PrivateKey string `json:"PrivateKey"`
|
||||
PublicKey string `json:"PublicKey"`
|
||||
|
||||
InterfaceType string `json:"InterfaceType"`
|
||||
|
||||
Addresses []string `json:"Addresses"`
|
||||
CheckAliveAddress string `json:"CheckAliveAddress"`
|
||||
DnsStr string `json:"DnsStr"`
|
||||
DnsSearchStr string `json:"DnsSearchStr"`
|
||||
Mtu int `json:"Mtu"`
|
||||
FirewallMark uint32 `json:"FirewallMark,omitempty"`
|
||||
RoutingTable string `json:"RoutingTable,omitempty"`
|
||||
|
||||
PreUp string `json:"PreUp,omitempty"`
|
||||
PostUp string `json:"PostUp,omitempty"`
|
||||
PreDown string `json:"PreDown,omitempty"`
|
||||
PostDown string `json:"PostDown,omitempty"`
|
||||
}
|
||||
|
||||
// NewPeer creates a new Peer model from a domain.Peer.
|
||||
func NewPeer(src domain.Peer) Peer {
|
||||
return Peer{
|
||||
CreatedBy: src.CreatedBy,
|
||||
UpdatedBy: src.UpdatedBy,
|
||||
CreatedAt: src.CreatedAt,
|
||||
UpdatedAt: src.UpdatedAt,
|
||||
Endpoint: src.Endpoint.GetValue(),
|
||||
EndpointPublicKey: src.EndpointPublicKey.GetValue(),
|
||||
AllowedIPsStr: src.AllowedIPsStr.GetValue(),
|
||||
ExtraAllowedIPsStr: src.ExtraAllowedIPsStr,
|
||||
PresharedKey: string(src.PresharedKey),
|
||||
PersistentKeepalive: src.PersistentKeepalive.GetValue(),
|
||||
DisplayName: src.DisplayName,
|
||||
Identifier: string(src.Identifier),
|
||||
UserIdentifier: string(src.UserIdentifier),
|
||||
InterfaceIdentifier: string(src.InterfaceIdentifier),
|
||||
Disabled: src.Disabled,
|
||||
DisabledReason: src.DisabledReason,
|
||||
ExpiresAt: src.ExpiresAt,
|
||||
Notes: src.Notes,
|
||||
AutomaticallyCreated: src.AutomaticallyCreated,
|
||||
PrivateKey: src.Interface.KeyPair.PrivateKey,
|
||||
PublicKey: src.Interface.KeyPair.PublicKey,
|
||||
InterfaceType: string(src.Interface.Type),
|
||||
Addresses: domain.CidrsToStringSlice(src.Interface.Addresses),
|
||||
CheckAliveAddress: src.Interface.CheckAliveAddress,
|
||||
DnsStr: src.Interface.DnsStr.GetValue(),
|
||||
DnsSearchStr: src.Interface.DnsSearchStr.GetValue(),
|
||||
Mtu: src.Interface.Mtu.GetValue(),
|
||||
FirewallMark: src.Interface.FirewallMark.GetValue(),
|
||||
RoutingTable: src.Interface.RoutingTable.GetValue(),
|
||||
PreUp: src.Interface.PreUp.GetValue(),
|
||||
PostUp: src.Interface.PostUp.GetValue(),
|
||||
PreDown: src.Interface.PreDown.GetValue(),
|
||||
PostDown: src.Interface.PostDown.GetValue(),
|
||||
}
|
||||
}
|
@@ -1,50 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
// PeerMetrics represents a peer metrics model for webhooks.
|
||||
// For details about the fields, see the domain.PeerStatus and domain.Peer structs.
|
||||
type PeerMetrics struct {
|
||||
Status PeerStatus `json:"Status"`
|
||||
Peer Peer `json:"Peer"`
|
||||
}
|
||||
|
||||
// PeerStatus represents the status of a peer for webhooks.
|
||||
// For details about the fields, see the domain.PeerStatus struct.
|
||||
type PeerStatus struct {
|
||||
UpdatedAt time.Time `json:"UpdatedAt"`
|
||||
|
||||
IsConnected bool `json:"IsConnected"`
|
||||
|
||||
IsPingable bool `json:"IsPingable"`
|
||||
LastPing *time.Time `json:"LastPing,omitempty"`
|
||||
|
||||
BytesReceived uint64 `json:"BytesReceived"`
|
||||
BytesTransmitted uint64 `json:"BytesTransmitted"`
|
||||
|
||||
Endpoint string `json:"Endpoint"`
|
||||
LastHandshake *time.Time `json:"LastHandshake,omitempty"`
|
||||
LastSessionStart *time.Time `json:"LastSessionStart,omitempty"`
|
||||
}
|
||||
|
||||
// NewPeerMetrics creates a new PeerMetrics model from the domain.PeerStatus and domain.Peer models.
|
||||
func NewPeerMetrics(status domain.PeerStatus, peer domain.Peer) PeerMetrics {
|
||||
return PeerMetrics{
|
||||
Status: PeerStatus{
|
||||
UpdatedAt: status.UpdatedAt,
|
||||
IsConnected: status.IsConnected,
|
||||
IsPingable: status.IsPingable,
|
||||
LastPing: status.LastPing,
|
||||
BytesReceived: status.BytesReceived,
|
||||
BytesTransmitted: status.BytesTransmitted,
|
||||
Endpoint: status.Endpoint,
|
||||
LastHandshake: status.LastHandshake,
|
||||
LastSessionStart: status.LastSessionStart,
|
||||
},
|
||||
Peer: NewPeer(peer),
|
||||
}
|
||||
}
|
@@ -1,56 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
// User represents a user model for webhooks. For details about the fields, see the domain.User struct.
|
||||
type User struct {
|
||||
CreatedBy string `json:"CreatedBy"`
|
||||
UpdatedBy string `json:"UpdatedBy"`
|
||||
CreatedAt time.Time `json:"CreatedAt"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt"`
|
||||
|
||||
Identifier string `json:"Identifier"`
|
||||
Email string `json:"Email"`
|
||||
Source string `json:"Source"`
|
||||
ProviderName string `json:"ProviderName"`
|
||||
IsAdmin bool `json:"IsAdmin"`
|
||||
|
||||
Firstname string `json:"Firstname,omitempty"`
|
||||
Lastname string `json:"Lastname,omitempty"`
|
||||
Phone string `json:"Phone,omitempty"`
|
||||
Department string `json:"Department,omitempty"`
|
||||
Notes string `json:"Notes,omitempty"`
|
||||
|
||||
Disabled *time.Time `json:"Disabled,omitempty"`
|
||||
DisabledReason string `json:"DisabledReason,omitempty"`
|
||||
Locked *time.Time `json:"Locked,omitempty"`
|
||||
LockedReason string `json:"LockedReason,omitempty"`
|
||||
}
|
||||
|
||||
// NewUser creates a new User model from a domain.User
|
||||
func NewUser(src domain.User) User {
|
||||
return User{
|
||||
CreatedBy: src.CreatedBy,
|
||||
UpdatedBy: src.UpdatedBy,
|
||||
CreatedAt: src.CreatedAt,
|
||||
UpdatedAt: src.UpdatedAt,
|
||||
Identifier: string(src.Identifier),
|
||||
Email: src.Email,
|
||||
Source: string(src.Source),
|
||||
ProviderName: src.ProviderName,
|
||||
IsAdmin: src.IsAdmin,
|
||||
Firstname: src.Firstname,
|
||||
Lastname: src.Lastname,
|
||||
Phone: src.Phone,
|
||||
Department: src.Department,
|
||||
Notes: src.Notes,
|
||||
Disabled: src.Disabled,
|
||||
DisabledReason: src.DisabledReason,
|
||||
Locked: src.Locked,
|
||||
LockedReason: src.LockedReason,
|
||||
}
|
||||
}
|
@@ -1,166 +0,0 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/adapters/wgcontroller"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
type InterfaceController interface {
|
||||
GetId() domain.InterfaceBackend
|
||||
GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error)
|
||||
GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error)
|
||||
GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error)
|
||||
SaveInterface(
|
||||
_ context.Context,
|
||||
id domain.InterfaceIdentifier,
|
||||
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
|
||||
) error
|
||||
DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error
|
||||
SavePeer(
|
||||
_ context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
|
||||
) error
|
||||
DeletePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error
|
||||
PingAddresses(
|
||||
ctx context.Context,
|
||||
addr string,
|
||||
) (*domain.PingerResult, error)
|
||||
}
|
||||
|
||||
type backendInstance struct {
|
||||
Config config.BackendBase // Config is the configuration for the backend instance.
|
||||
Implementation InterfaceController
|
||||
}
|
||||
|
||||
type ControllerManager struct {
|
||||
cfg *config.Config
|
||||
controllers map[domain.InterfaceBackend]backendInstance
|
||||
}
|
||||
|
||||
func NewControllerManager(cfg *config.Config) (*ControllerManager, error) {
|
||||
c := &ControllerManager{
|
||||
cfg: cfg,
|
||||
controllers: make(map[domain.InterfaceBackend]backendInstance),
|
||||
}
|
||||
|
||||
err := c.init()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *ControllerManager) init() error {
|
||||
if err := c.registerLocalController(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.registerMikrotikControllers(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.logRegisteredControllers()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ControllerManager) registerLocalController() error {
|
||||
localController, err := wgcontroller.NewLocalController(c.cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create local WireGuard controller: %w", err)
|
||||
}
|
||||
|
||||
c.controllers[config.LocalBackendName] = backendInstance{
|
||||
Config: config.BackendBase{
|
||||
Id: config.LocalBackendName,
|
||||
DisplayName: "Local WireGuard Controller",
|
||||
},
|
||||
Implementation: localController,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ControllerManager) registerMikrotikControllers() error {
|
||||
for _, backendConfig := range c.cfg.Backend.Mikrotik {
|
||||
if backendConfig.Id == config.LocalBackendName {
|
||||
slog.Warn("skipping registration of Mikrotik controller with reserved ID", "id", config.LocalBackendName)
|
||||
continue
|
||||
}
|
||||
|
||||
controller, err := wgcontroller.NewMikrotikController(c.cfg, &backendConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Mikrotik controller for backend %s: %w", backendConfig.Id, err)
|
||||
}
|
||||
|
||||
c.controllers[domain.InterfaceBackend(backendConfig.Id)] = backendInstance{
|
||||
Config: backendConfig.BackendBase,
|
||||
Implementation: controller,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ControllerManager) logRegisteredControllers() {
|
||||
for backend, controller := range c.controllers {
|
||||
slog.Debug("backend controller registered",
|
||||
"backend", backend, "type", fmt.Sprintf("%T", controller.Implementation))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ControllerManager) GetControllerByName(backend domain.InterfaceBackend) InterfaceController {
|
||||
return c.getController(backend, "")
|
||||
}
|
||||
|
||||
func (c *ControllerManager) GetController(iface domain.Interface) InterfaceController {
|
||||
return c.getController(iface.Backend, iface.Identifier)
|
||||
}
|
||||
|
||||
func (c *ControllerManager) getController(
|
||||
backend domain.InterfaceBackend,
|
||||
ifaceId domain.InterfaceIdentifier,
|
||||
) InterfaceController {
|
||||
if backend == "" {
|
||||
// If no backend is specified, use the local controller.
|
||||
// This might be the case for interfaces created in previous WireGuard Portal versions.
|
||||
backend = config.LocalBackendName
|
||||
}
|
||||
|
||||
controller, exists := c.controllers[backend]
|
||||
if !exists {
|
||||
controller, exists = c.controllers[config.LocalBackendName] // Fallback to local controller
|
||||
if !exists {
|
||||
// If the local controller is also not found, panic
|
||||
panic(fmt.Sprintf("%s interface controller for backend %s not found", ifaceId, backend))
|
||||
}
|
||||
slog.Warn("controller for backend not found, using local controller",
|
||||
"backend", backend, "interface", ifaceId)
|
||||
}
|
||||
return controller.Implementation
|
||||
}
|
||||
|
||||
func (c *ControllerManager) GetAllControllers() []InterfaceController {
|
||||
var backendInstances = make([]InterfaceController, 0, len(c.controllers))
|
||||
for instance := range maps.Values(c.controllers) {
|
||||
backendInstances = append(backendInstances, instance.Implementation)
|
||||
}
|
||||
return backendInstances
|
||||
}
|
||||
|
||||
func (c *ControllerManager) GetControllerNames() []config.BackendBase {
|
||||
var names []config.BackendBase
|
||||
for _, id := range slices.Sorted(maps.Keys(c.controllers)) {
|
||||
names = append(names, c.controllers[id].Config)
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
@@ -6,6 +6,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
probing "github.com/prometheus-community/pro-bing"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/app"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
@@ -28,6 +30,11 @@ type StatisticsDatabaseRepo interface {
|
||||
DeletePeerStatus(ctx context.Context, id domain.PeerIdentifier) error
|
||||
}
|
||||
|
||||
type StatisticsInterfaceController interface {
|
||||
GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error)
|
||||
GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error)
|
||||
}
|
||||
|
||||
type StatisticsMetricsServer interface {
|
||||
UpdateInterfaceMetrics(status domain.InterfaceStatus)
|
||||
UpdatePeerMetrics(peer *domain.Peer, status domain.PeerStatus)
|
||||
@@ -36,13 +43,6 @@ type StatisticsMetricsServer interface {
|
||||
type StatisticsEventBus interface {
|
||||
// Subscribe subscribes to a topic
|
||||
Subscribe(topic string, fn interface{}) error
|
||||
// Publish sends a message to the message bus.
|
||||
Publish(topic string, args ...any)
|
||||
}
|
||||
|
||||
type pingJob struct {
|
||||
Peer domain.Peer
|
||||
Backend domain.InterfaceBackend
|
||||
}
|
||||
|
||||
type StatisticsCollector struct {
|
||||
@@ -50,13 +50,11 @@ type StatisticsCollector struct {
|
||||
bus StatisticsEventBus
|
||||
|
||||
pingWaitGroup sync.WaitGroup
|
||||
pingJobs chan pingJob
|
||||
pingJobs chan domain.Peer
|
||||
|
||||
db StatisticsDatabaseRepo
|
||||
wg *ControllerManager
|
||||
wg StatisticsInterfaceController
|
||||
ms StatisticsMetricsServer
|
||||
|
||||
peerChangeEvent chan domain.PeerIdentifier
|
||||
}
|
||||
|
||||
// NewStatisticsCollector creates a new statistics collector.
|
||||
@@ -64,7 +62,7 @@ func NewStatisticsCollector(
|
||||
cfg *config.Config,
|
||||
bus StatisticsEventBus,
|
||||
db StatisticsDatabaseRepo,
|
||||
wg *ControllerManager,
|
||||
wg StatisticsInterfaceController,
|
||||
ms StatisticsMetricsServer,
|
||||
) (*StatisticsCollector, error) {
|
||||
c := &StatisticsCollector{
|
||||
@@ -115,7 +113,7 @@ func (c *StatisticsCollector) collectInterfaceData(ctx context.Context) {
|
||||
}
|
||||
|
||||
for _, in := range interfaces {
|
||||
physicalInterface, err := c.wg.GetController(in).GetInterface(ctx, in.Identifier)
|
||||
physicalInterface, err := c.wg.GetInterface(ctx, in.Identifier)
|
||||
if err != nil {
|
||||
slog.Warn("failed to load physical interface for data collection", "interface", in.Identifier,
|
||||
"error", err)
|
||||
@@ -167,18 +165,14 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
|
||||
}
|
||||
|
||||
for _, in := range interfaces {
|
||||
peers, err := c.wg.GetController(in).GetPeers(ctx, in.Identifier)
|
||||
peers, err := c.wg.GetPeers(ctx, in.Identifier)
|
||||
if err != nil {
|
||||
slog.Warn("failed to fetch peers for data collection", "interface", in.Identifier, "error", err)
|
||||
continue
|
||||
}
|
||||
for _, peer := range peers {
|
||||
var connectionStateChanged bool
|
||||
var newPeerStatus domain.PeerStatus
|
||||
err = c.db.UpdatePeerStatus(ctx, peer.Identifier,
|
||||
func(p *domain.PeerStatus) (*domain.PeerStatus, error) {
|
||||
wasConnected := p.IsConnected
|
||||
|
||||
var lastHandshake *time.Time
|
||||
if !peer.LastHandshake.IsZero() {
|
||||
lastHandshake = &peer.LastHandshake
|
||||
@@ -192,13 +186,6 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
|
||||
p.BytesTransmitted = peer.BytesDownload // store bytes that where received from the peer and sent by the server
|
||||
p.Endpoint = peer.Endpoint
|
||||
p.LastHandshake = lastHandshake
|
||||
p.CalcConnected()
|
||||
|
||||
if wasConnected != p.IsConnected {
|
||||
slog.Debug("peer connection state changed", "peer", peer.Identifier, "connected", p.IsConnected)
|
||||
connectionStateChanged = true
|
||||
newPeerStatus = *p // store new status for event publishing
|
||||
}
|
||||
|
||||
// Update prometheus metrics
|
||||
go c.updatePeerMetrics(ctx, *p)
|
||||
@@ -210,17 +197,6 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
|
||||
} else {
|
||||
slog.Debug("updated peer status", "peer", peer.Identifier)
|
||||
}
|
||||
|
||||
if connectionStateChanged {
|
||||
peerModel, err := c.db.GetPeer(ctx, peer.Identifier)
|
||||
if err != nil {
|
||||
slog.Error("failed to fetch peer for data collection", "peer", peer.Identifier, "error",
|
||||
err)
|
||||
continue
|
||||
}
|
||||
// publish event if connection state changed
|
||||
c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus, *peerModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,7 +245,7 @@ func (c *StatisticsCollector) startPingWorkers(ctx context.Context) {
|
||||
|
||||
c.pingWaitGroup = sync.WaitGroup{}
|
||||
c.pingWaitGroup.Add(c.cfg.Statistics.PingCheckWorkers)
|
||||
c.pingJobs = make(chan pingJob, c.cfg.Statistics.PingCheckWorkers)
|
||||
c.pingJobs = make(chan domain.Peer, c.cfg.Statistics.PingCheckWorkers)
|
||||
|
||||
// start workers
|
||||
for i := 0; i < c.cfg.Statistics.PingCheckWorkers; i++ {
|
||||
@@ -312,10 +288,7 @@ func (c *StatisticsCollector) enqueuePingChecks(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
for _, peer := range peers {
|
||||
c.pingJobs <- pingJob{
|
||||
Peer: peer,
|
||||
Backend: in.Backend,
|
||||
}
|
||||
c.pingJobs <- peer
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,21 +297,13 @@ func (c *StatisticsCollector) enqueuePingChecks(ctx context.Context) {
|
||||
|
||||
func (c *StatisticsCollector) pingWorker(ctx context.Context) {
|
||||
defer c.pingWaitGroup.Done()
|
||||
for job := range c.pingJobs {
|
||||
peer := job.Peer
|
||||
backend := job.Backend
|
||||
|
||||
var connectionStateChanged bool
|
||||
var newPeerStatus domain.PeerStatus
|
||||
|
||||
peerPingable := c.isPeerPingable(ctx, backend, peer)
|
||||
for peer := range c.pingJobs {
|
||||
peerPingable := c.isPeerPingable(ctx, peer)
|
||||
slog.Debug("peer ping check completed", "peer", peer.Identifier, "pingable", peerPingable)
|
||||
|
||||
now := time.Now()
|
||||
err := c.db.UpdatePeerStatus(ctx, peer.Identifier,
|
||||
func(p *domain.PeerStatus) (*domain.PeerStatus, error) {
|
||||
wasConnected := p.IsConnected
|
||||
|
||||
if peerPingable {
|
||||
p.IsPingable = true
|
||||
p.LastPing = &now
|
||||
@@ -346,13 +311,6 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) {
|
||||
p.IsPingable = false
|
||||
p.LastPing = nil
|
||||
}
|
||||
p.UpdatedAt = time.Now()
|
||||
p.CalcConnected()
|
||||
|
||||
if wasConnected != p.IsConnected {
|
||||
connectionStateChanged = true
|
||||
newPeerStatus = *p // store new status for event publishing
|
||||
}
|
||||
|
||||
// Update prometheus metrics
|
||||
go c.updatePeerMetrics(ctx, *p)
|
||||
@@ -364,19 +322,10 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) {
|
||||
} else {
|
||||
slog.Debug("updated peer ping status", "peer", peer.Identifier)
|
||||
}
|
||||
|
||||
if connectionStateChanged {
|
||||
// publish event if connection state changed
|
||||
c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus, peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *StatisticsCollector) isPeerPingable(
|
||||
ctx context.Context,
|
||||
backend domain.InterfaceBackend,
|
||||
peer domain.Peer,
|
||||
) bool {
|
||||
func (c *StatisticsCollector) isPeerPingable(ctx context.Context, peer domain.Peer) bool {
|
||||
if !c.cfg.Statistics.UsePingChecks {
|
||||
return false
|
||||
}
|
||||
@@ -386,13 +335,23 @@ func (c *StatisticsCollector) isPeerPingable(
|
||||
return false
|
||||
}
|
||||
|
||||
stats, err := c.wg.GetControllerByName(backend).PingAddresses(ctx, checkAddr)
|
||||
pinger, err := probing.NewPinger(checkAddr)
|
||||
if err != nil {
|
||||
slog.Debug("failed to ping peer", "peer", peer.Identifier, "error", err)
|
||||
slog.Debug("failed to instantiate pinger", "peer", peer.Identifier, "address", checkAddr, "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return stats.IsPingable()
|
||||
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 {
|
||||
slog.Debug("pinger for peer exited unexpectedly", "peer", peer.Identifier, "address", checkAddr, "error", err)
|
||||
return false
|
||||
}
|
||||
stats := pinger.Statistics()
|
||||
return stats.PacketsRecv == checkCount
|
||||
}
|
||||
|
||||
func (c *StatisticsCollector) updateInterfaceMetrics(status domain.InterfaceStatus) {
|
||||
|
@@ -37,6 +37,25 @@ type InterfaceAndPeerDatabaseRepo interface {
|
||||
GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr) (map[domain.Cidr][]domain.Cidr, error)
|
||||
}
|
||||
|
||||
type InterfaceController interface {
|
||||
GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error)
|
||||
GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error)
|
||||
GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error)
|
||||
SaveInterface(
|
||||
_ context.Context,
|
||||
id domain.InterfaceIdentifier,
|
||||
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
|
||||
) error
|
||||
DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error
|
||||
SavePeer(
|
||||
_ context.Context,
|
||||
deviceId domain.InterfaceIdentifier,
|
||||
id domain.PeerIdentifier,
|
||||
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
|
||||
) error
|
||||
DeletePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error
|
||||
}
|
||||
|
||||
type WgQuickController interface {
|
||||
ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error
|
||||
SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error
|
||||
@@ -56,7 +75,7 @@ type Manager struct {
|
||||
cfg *config.Config
|
||||
bus EventBus
|
||||
db InterfaceAndPeerDatabaseRepo
|
||||
wg *ControllerManager
|
||||
wg InterfaceController
|
||||
quick WgQuickController
|
||||
|
||||
userLockMap *sync.Map
|
||||
@@ -65,7 +84,7 @@ type Manager struct {
|
||||
func NewWireGuardManager(
|
||||
cfg *config.Config,
|
||||
bus EventBus,
|
||||
wg *ControllerManager,
|
||||
wg InterfaceController,
|
||||
quick WgQuickController,
|
||||
db InterfaceAndPeerDatabaseRepo,
|
||||
) (*Manager, error) {
|
||||
|
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -22,17 +21,12 @@ func (m Manager) GetImportableInterfaces(ctx context.Context) ([]domain.Physical
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allPhysicalInterfaces []domain.PhysicalInterface
|
||||
for _, wgBackend := range m.wg.GetAllControllers() {
|
||||
physicalInterfaces, err := wgBackend.GetInterfaces(ctx)
|
||||
physicalInterfaces, err := m.wg.GetInterfaces(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allPhysicalInterfaces = append(allPhysicalInterfaces, physicalInterfaces...)
|
||||
}
|
||||
|
||||
return allPhysicalInterfaces, nil
|
||||
return physicalInterfaces, nil
|
||||
}
|
||||
|
||||
// GetInterfaceAndPeers returns the interface and all peers for the given interface identifier.
|
||||
@@ -115,9 +109,7 @@ func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.Inter
|
||||
return 0, err
|
||||
}
|
||||
|
||||
imported := 0
|
||||
for _, wgBackend := range m.wg.GetAllControllers() {
|
||||
physicalInterfaces, err := wgBackend.GetInterfaces(ctx)
|
||||
physicalInterfaces, err := m.wg.GetInterfaces(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -134,6 +126,7 @@ func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.Inter
|
||||
}
|
||||
}
|
||||
|
||||
imported := 0
|
||||
for _, physicalInterface := range physicalInterfaces {
|
||||
if slices.Contains(excludedInterfaces, physicalInterface.Identifier) {
|
||||
continue
|
||||
@@ -145,12 +138,12 @@ func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.Inter
|
||||
|
||||
slog.Info("importing new interface", "interface", physicalInterface.Identifier)
|
||||
|
||||
physicalPeers, err := wgBackend.GetPeers(ctx, physicalInterface.Identifier)
|
||||
physicalPeers, err := m.wg.GetPeers(ctx, physicalInterface.Identifier)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
err = m.importInterface(ctx, wgBackend, &physicalInterface, physicalPeers)
|
||||
err = m.importInterface(ctx, &physicalInterface, physicalPeers)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err)
|
||||
}
|
||||
@@ -158,7 +151,6 @@ func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.Inter
|
||||
slog.Info("imported new interface", "interface", physicalInterface.Identifier, "peers", len(physicalPeers))
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
||||
return imported, nil
|
||||
}
|
||||
@@ -221,7 +213,7 @@ func (m Manager) RestoreInterfaceState(
|
||||
return fmt.Errorf("failed to load peers for %s: %w", iface.Identifier, err)
|
||||
}
|
||||
|
||||
_, err = m.wg.GetController(iface).GetInterface(ctx, iface.Identifier)
|
||||
_, err = m.wg.GetInterface(ctx, iface.Identifier)
|
||||
if err != nil && !iface.IsDisabled() {
|
||||
slog.Debug("creating missing interface", "interface", iface.Identifier)
|
||||
|
||||
@@ -277,14 +269,18 @@ func (m Manager) RestoreInterfaceState(
|
||||
// restore peers
|
||||
for _, peer := range peers {
|
||||
switch {
|
||||
case iface.IsDisabled() && iface.Backend == config.LocalBackendName: // if interface is disabled, delete all peers
|
||||
if err := m.wg.GetController(iface).DeletePeer(ctx, iface.Identifier,
|
||||
peer.Identifier); err != nil {
|
||||
case iface.IsDisabled(): // if interface is disabled, delete all peers
|
||||
if err := m.wg.DeletePeer(ctx, iface.Identifier, peer.Identifier); err != nil {
|
||||
return fmt.Errorf("failed to remove peer %s for disabled interface %s: %w",
|
||||
peer.Identifier, iface.Identifier, err)
|
||||
}
|
||||
case peer.IsDisabled(): // if peer is disabled, delete it
|
||||
if err := m.wg.DeletePeer(ctx, iface.Identifier, peer.Identifier); err != nil {
|
||||
return fmt.Errorf("failed to remove disbaled peer %s from interface %s: %w",
|
||||
peer.Identifier, iface.Identifier, err)
|
||||
}
|
||||
default: // update peer
|
||||
err := m.wg.GetController(iface).SavePeer(ctx, iface.Identifier, peer.Identifier,
|
||||
err := m.wg.SavePeer(ctx, iface.Identifier, peer.Identifier,
|
||||
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
|
||||
domain.MergeToPhysicalPeer(pp, &peer)
|
||||
return pp, nil
|
||||
@@ -297,7 +293,7 @@ func (m Manager) RestoreInterfaceState(
|
||||
}
|
||||
|
||||
// remove non-wgportal peers
|
||||
physicalPeers, _ := m.wg.GetController(iface).GetPeers(ctx, iface.Identifier)
|
||||
physicalPeers, _ := m.wg.GetPeers(ctx, iface.Identifier)
|
||||
for _, physicalPeer := range physicalPeers {
|
||||
isWgPortalPeer := false
|
||||
for _, peer := range peers {
|
||||
@@ -307,8 +303,7 @@ func (m Manager) RestoreInterfaceState(
|
||||
}
|
||||
}
|
||||
if !isWgPortalPeer {
|
||||
err := m.wg.GetController(iface).DeletePeer(ctx, iface.Identifier,
|
||||
domain.PeerIdentifier(physicalPeer.PublicKey))
|
||||
err := m.wg.DeletePeer(ctx, iface.Identifier, domain.PeerIdentifier(physicalPeer.PublicKey))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove non-wgportal peer %s from interface %s: %w",
|
||||
physicalPeer.PublicKey, iface.Identifier, err)
|
||||
@@ -473,9 +468,9 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
|
||||
existingInterface.Disabled = &now // simulate a disabled interface
|
||||
existingInterface.DisabledReason = domain.DisabledReasonDeleted
|
||||
|
||||
physicalInterface, _ := m.wg.GetController(*existingInterface).GetInterface(ctx, id)
|
||||
physicalInterface, _ := m.wg.GetInterface(ctx, id)
|
||||
|
||||
if err := m.handleInterfacePreSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil {
|
||||
if err := m.handleInterfacePreSaveHooks(true, existingInterface); err != nil {
|
||||
return fmt.Errorf("pre-delete hooks failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -487,7 +482,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
|
||||
return fmt.Errorf("peer deletion failure: %w", err)
|
||||
}
|
||||
|
||||
if err := m.wg.GetController(*existingInterface).DeleteInterface(ctx, id); err != nil {
|
||||
if err := m.wg.DeleteInterface(ctx, id); err != nil {
|
||||
return fmt.Errorf("wireguard deletion failure: %w", err)
|
||||
}
|
||||
|
||||
@@ -504,7 +499,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
|
||||
Table: existingInterface.GetRoutingTable(),
|
||||
})
|
||||
|
||||
if err := m.handleInterfacePostSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil {
|
||||
if err := m.handleInterfacePostSaveHooks(true, existingInterface); err != nil {
|
||||
return fmt.Errorf("post-delete hooks failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -523,9 +518,9 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
|
||||
return nil, fmt.Errorf("interface validation failed: %w", err)
|
||||
}
|
||||
|
||||
oldEnabled, newEnabled := m.getInterfaceStateHistory(ctx, iface)
|
||||
stateChanged := m.hasInterfaceStateChanged(ctx, iface)
|
||||
|
||||
if err := m.handleInterfacePreSaveHooks(iface, oldEnabled, newEnabled); err != nil {
|
||||
if err := m.handleInterfacePreSaveHooks(stateChanged, iface); err != nil {
|
||||
return nil, fmt.Errorf("pre-save hooks failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -536,7 +531,7 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
|
||||
err := m.db.SaveInterface(ctx, iface.Identifier, func(i *domain.Interface) (*domain.Interface, error) {
|
||||
iface.CopyCalculatedAttributes(i)
|
||||
|
||||
err := m.wg.GetController(*iface).SaveInterface(ctx, iface.Identifier,
|
||||
err := m.wg.SaveInterface(ctx, iface.Identifier,
|
||||
func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
|
||||
domain.MergeToPhysicalInterface(pi, iface)
|
||||
return pi, nil
|
||||
@@ -552,7 +547,7 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
|
||||
}
|
||||
|
||||
if iface.IsDisabled() {
|
||||
physicalInterface, _ := m.wg.GetController(*iface).GetInterface(ctx, iface.Identifier)
|
||||
physicalInterface, _ := m.wg.GetInterface(ctx, iface.Identifier)
|
||||
fwMark := iface.FirewallMark
|
||||
if physicalInterface != nil && fwMark == 0 {
|
||||
fwMark = physicalInterface.FirewallMark
|
||||
@@ -565,31 +560,10 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
|
||||
m.bus.Publish(app.TopicRouteUpdate, "interface updated: "+string(iface.Identifier))
|
||||
}
|
||||
|
||||
if err := m.handleInterfacePostSaveHooks(iface, oldEnabled, newEnabled); err != nil {
|
||||
if err := m.handleInterfacePostSaveHooks(stateChanged, iface); err != nil {
|
||||
return nil, fmt.Errorf("post-save hooks failed: %w", err)
|
||||
}
|
||||
|
||||
// If the interface has just been enabled, restore its peers on the physical controller
|
||||
if !oldEnabled && newEnabled && iface.Backend == config.LocalBackendName {
|
||||
peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load peers for interface %s: %w", iface.Identifier, err)
|
||||
}
|
||||
for _, peer := range peers {
|
||||
saveErr := m.wg.GetController(*iface).SavePeer(ctx, iface.Identifier, peer.Identifier,
|
||||
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
|
||||
domain.MergeToPhysicalPeer(pp, &peer)
|
||||
return pp, nil
|
||||
})
|
||||
if saveErr != nil {
|
||||
return nil, fmt.Errorf("failed to restore peer %s for interface %s: %w", peer.Identifier,
|
||||
iface.Identifier, saveErr)
|
||||
}
|
||||
}
|
||||
// notify that peers for this interface have changed so config/routes can be updated
|
||||
m.bus.Publish(app.TopicPeerInterfaceUpdated, iface.Identifier)
|
||||
}
|
||||
|
||||
m.bus.Publish(app.TopicAuditInterfaceChanged, domain.AuditEventWrapper[audit.InterfaceEvent]{
|
||||
Ctx: ctx,
|
||||
Event: audit.InterfaceEvent{
|
||||
@@ -601,13 +575,32 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
|
||||
return iface, nil
|
||||
}
|
||||
|
||||
func (m Manager) getInterfaceStateHistory(ctx context.Context, iface *domain.Interface) (oldEnabled, newEnabled bool) {
|
||||
func (m Manager) hasInterfaceStateChanged(ctx context.Context, iface *domain.Interface) bool {
|
||||
oldInterface, err := m.db.GetInterface(ctx, iface.Identifier)
|
||||
if err != nil {
|
||||
return false, !iface.IsDisabled() // if the interface did not exist, we assume it was not enabled
|
||||
return false
|
||||
}
|
||||
|
||||
return !oldInterface.IsDisabled(), !iface.IsDisabled()
|
||||
if oldInterface.IsDisabled() != iface.IsDisabled() {
|
||||
return true // interface in db has changed
|
||||
}
|
||||
|
||||
wgInterface, err := m.wg.GetInterface(ctx, iface.Identifier)
|
||||
if err != nil {
|
||||
return true // interface might not exist - so we assume that there must be a change
|
||||
}
|
||||
|
||||
// compare physical interface settings
|
||||
if len(wgInterface.Addresses) != len(iface.Addresses) ||
|
||||
wgInterface.Mtu != iface.Mtu ||
|
||||
wgInterface.FirewallMark != iface.FirewallMark ||
|
||||
wgInterface.ListenPort != iface.ListenPort ||
|
||||
wgInterface.PrivateKey != iface.PrivateKey ||
|
||||
wgInterface.PublicKey != iface.PublicKey {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (m Manager) handleInterfacePreSaveActions(iface *domain.Interface) error {
|
||||
@@ -623,14 +616,12 @@ func (m Manager) handleInterfacePreSaveActions(iface *domain.Interface) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) handleInterfacePreSaveHooks(iface *domain.Interface, oldEnabled, newEnabled bool) error {
|
||||
if oldEnabled == newEnabled {
|
||||
func (m Manager) handleInterfacePreSaveHooks(stateChanged bool, iface *domain.Interface) error {
|
||||
if !stateChanged {
|
||||
return nil // do nothing if state did not change
|
||||
}
|
||||
|
||||
slog.Debug("executing pre-save hooks", "interface", iface.Identifier, "up", newEnabled)
|
||||
|
||||
if newEnabled {
|
||||
if !iface.IsDisabled() {
|
||||
if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PreUp); err != nil {
|
||||
return fmt.Errorf("failed to execute pre-up hook: %w", err)
|
||||
}
|
||||
@@ -642,14 +633,12 @@ func (m Manager) handleInterfacePreSaveHooks(iface *domain.Interface, oldEnabled
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) handleInterfacePostSaveHooks(iface *domain.Interface, oldEnabled, newEnabled bool) error {
|
||||
if oldEnabled == newEnabled {
|
||||
func (m Manager) handleInterfacePostSaveHooks(stateChanged bool, iface *domain.Interface) error {
|
||||
if !stateChanged {
|
||||
return nil // do nothing if state did not change
|
||||
}
|
||||
|
||||
slog.Debug("executing post-save hooks", "interface", iface.Identifier, "up", newEnabled)
|
||||
|
||||
if newEnabled {
|
||||
if !iface.IsDisabled() {
|
||||
if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PostUp); err != nil {
|
||||
return fmt.Errorf("failed to execute post-up hook: %w", err)
|
||||
}
|
||||
@@ -780,12 +769,7 @@ func (m Manager) getFreshListenPort(ctx context.Context) (port int, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (m Manager) importInterface(
|
||||
ctx context.Context,
|
||||
backend InterfaceController,
|
||||
in *domain.PhysicalInterface,
|
||||
peers []domain.PhysicalPeer,
|
||||
) error {
|
||||
func (m Manager) importInterface(ctx context.Context, in *domain.PhysicalInterface, peers []domain.PhysicalPeer) error {
|
||||
now := time.Now()
|
||||
iface := domain.ConvertPhysicalInterface(in)
|
||||
iface.BaseModel = domain.BaseModel{
|
||||
@@ -794,20 +778,8 @@ func (m Manager) importInterface(
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
iface.Backend = backend.GetId()
|
||||
iface.PeerDefAllowedIPsStr = iface.AddressStr()
|
||||
|
||||
// try to predict the interface type based on the number of peers
|
||||
switch len(peers) {
|
||||
case 0:
|
||||
iface.Type = domain.InterfaceTypeAny // no peers means this is an unknown interface
|
||||
case 1:
|
||||
iface.Type = domain.InterfaceTypeClient // one peer means this is a client interface
|
||||
default: // multiple peers means this is a server interface
|
||||
|
||||
iface.Type = domain.InterfaceTypeServer
|
||||
}
|
||||
|
||||
existingInterface, err := m.db.GetInterface(ctx, iface.Identifier)
|
||||
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
||||
return err
|
||||
@@ -858,20 +830,16 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain
|
||||
peer.Interface.PreDown = domain.NewConfigOption(in.PeerDefPreDown, true)
|
||||
peer.Interface.PostDown = domain.NewConfigOption(in.PeerDefPostDown, true)
|
||||
|
||||
var displayName string
|
||||
switch in.Type {
|
||||
case domain.InterfaceTypeAny:
|
||||
peer.Interface.Type = domain.InterfaceTypeAny
|
||||
displayName = "Autodetected Peer (" + peer.Interface.PublicKey[0:8] + ")"
|
||||
peer.DisplayName = "Autodetected Peer (" + peer.Interface.PublicKey[0:8] + ")"
|
||||
case domain.InterfaceTypeClient:
|
||||
peer.Interface.Type = domain.InterfaceTypeServer
|
||||
displayName = "Autodetected Endpoint (" + peer.Interface.PublicKey[0:8] + ")"
|
||||
peer.DisplayName = "Autodetected Endpoint (" + peer.Interface.PublicKey[0:8] + ")"
|
||||
case domain.InterfaceTypeServer:
|
||||
peer.Interface.Type = domain.InterfaceTypeClient
|
||||
displayName = "Autodetected Client (" + peer.Interface.PublicKey[0:8] + ")"
|
||||
}
|
||||
if peer.DisplayName == "" {
|
||||
peer.DisplayName = displayName // use auto-generated display name if not set
|
||||
peer.DisplayName = "Autodetected Client (" + peer.Interface.PublicKey[0:8] + ")"
|
||||
}
|
||||
|
||||
err := m.db.SavePeer(ctx, peer.Identifier, func(_ *domain.Peer) (*domain.Peer, error) {
|
||||
@@ -885,12 +853,12 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain
|
||||
}
|
||||
|
||||
func (m Manager) deleteInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) error {
|
||||
iface, allPeers, err := m.db.GetInterfaceAndPeers(ctx, id)
|
||||
allPeers, err := m.db.GetInterfacePeers(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, peer := range allPeers {
|
||||
err = m.wg.GetController(*iface).DeletePeer(ctx, id, peer.Identifier)
|
||||
err = m.wg.DeletePeer(ctx, id, peer.Identifier)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("wireguard peer deletion failure for %s: %w", peer.Identifier, err)
|
||||
}
|
||||
|
@@ -188,30 +188,6 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
|
||||
|
||||
sessionUser := domain.GetUserInfo(ctx)
|
||||
|
||||
// Enforce peer limit for non-admin users if LimitAdditionalUserPeers is set
|
||||
if m.cfg.Core.SelfProvisioningAllowed && !sessionUser.IsAdmin && m.cfg.Advanced.LimitAdditionalUserPeers > 0 {
|
||||
peers, err := m.db.GetUserPeers(ctx, peer.UserIdentifier)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch peers for user %s: %w", peer.UserIdentifier, err)
|
||||
}
|
||||
// Count enabled peers (disabled IS NULL)
|
||||
peerCount := 0
|
||||
for _, p := range peers {
|
||||
if !p.IsDisabled() {
|
||||
peerCount++
|
||||
}
|
||||
}
|
||||
totalAllowedPeers := 1 + m.cfg.Advanced.LimitAdditionalUserPeers // 1 default peer + x additional peers
|
||||
if peerCount >= totalAllowedPeers {
|
||||
slog.WarnContext(ctx, "peer creation blocked due to limit",
|
||||
"user", peer.UserIdentifier,
|
||||
"current_count", peerCount,
|
||||
"allowed_count", totalAllowedPeers)
|
||||
return nil, fmt.Errorf("peer limit reached (%d peers allowed): %w", totalAllowedPeers,
|
||||
domain.ErrNoPermission)
|
||||
}
|
||||
}
|
||||
|
||||
existingPeer, err := m.db.GetPeer(ctx, peer.Identifier)
|
||||
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
||||
return nil, fmt.Errorf("unable to load existing peer %s: %w", peer.Identifier, err)
|
||||
@@ -371,12 +347,7 @@ func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
|
||||
return fmt.Errorf("delete not allowed: %w", err)
|
||||
}
|
||||
|
||||
iface, err := m.db.GetInterface(ctx, peer.InterfaceIdentifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to find interface %s: %w", peer.InterfaceIdentifier, err)
|
||||
}
|
||||
|
||||
err = m.wg.GetController(*iface).DeletePeer(ctx, peer.InterfaceIdentifier, id)
|
||||
err = m.wg.DeletePeer(ctx, peer.InterfaceIdentifier, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wireguard failed to delete peer %s: %w", id, err)
|
||||
}
|
||||
@@ -438,18 +409,24 @@ func (m Manager) GetUserPeerStats(ctx context.Context, id domain.UserIdentifier)
|
||||
func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error {
|
||||
interfaces := make(map[domain.InterfaceIdentifier]struct{})
|
||||
|
||||
for _, peer := range peers {
|
||||
iface, err := m.db.GetInterface(ctx, peer.InterfaceIdentifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to find interface %s: %w", peer.InterfaceIdentifier, err)
|
||||
}
|
||||
|
||||
// Always save the peer to the backend, regardless of disabled/expired state
|
||||
// The backend will handle the disabled state appropriately
|
||||
for i := range peers {
|
||||
peer := peers[i]
|
||||
var err error
|
||||
if peer.IsDisabled() || peer.IsExpired() {
|
||||
err = m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) {
|
||||
peer.CopyCalculatedAttributes(p)
|
||||
|
||||
err := m.wg.GetController(*iface).SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier,
|
||||
if err := m.wg.DeletePeer(ctx, peer.InterfaceIdentifier, peer.Identifier); err != nil {
|
||||
return nil, fmt.Errorf("failed to delete wireguard peer %s: %w", peer.Identifier, err)
|
||||
}
|
||||
|
||||
return peer, nil
|
||||
})
|
||||
} else {
|
||||
err = m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) {
|
||||
peer.CopyCalculatedAttributes(p)
|
||||
|
||||
err := m.wg.SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier,
|
||||
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
|
||||
domain.MergeToPhysicalPeer(pp, peer)
|
||||
return pp, nil
|
||||
@@ -460,6 +437,7 @@ func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error {
|
||||
|
||||
return peer, nil
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("save failure for peer %s: %w", peer.Identifier, err)
|
||||
}
|
||||
|
@@ -16,14 +16,6 @@ type Auth struct {
|
||||
OAuth []OAuthProvider `yaml:"oauth"`
|
||||
// Ldap contains a list of LDAP providers.
|
||||
Ldap []LdapProvider `yaml:"ldap"`
|
||||
// Webauthn contains the configuration for the WebAuthn authenticator.
|
||||
WebAuthn WebauthnConfig `yaml:"webauthn"`
|
||||
// MinPasswordLength is the minimum password length for user accounts. This also applies to the admin user.
|
||||
// It is encouraged to set this value to at least 16 characters.
|
||||
MinPasswordLength int `yaml:"min_password_length"`
|
||||
// HideLoginForm specifies whether the login form should be hidden. If no social login providers are configured,
|
||||
// the login form will be shown regardless of this setting.
|
||||
HideLoginForm bool `yaml:"hide_login_form"`
|
||||
}
|
||||
|
||||
// BaseFields contains the basic fields that are used to map user information from the authentication providers.
|
||||
@@ -253,9 +245,3 @@ type OAuthProvider struct {
|
||||
// If LogUserInfo is set to true, the user info retrieved from the OAuth provider will be logged in trace level.
|
||||
LogUserInfo bool `yaml:"log_user_info"`
|
||||
}
|
||||
|
||||
// WebauthnConfig contains the configuration for the WebAuthn authenticator.
|
||||
type WebauthnConfig struct {
|
||||
// Enabled specifies whether WebAuthn is enabled.
|
||||
Enabled bool `yaml:"enabled"`
|
||||
}
|
||||
|
@@ -1,94 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const LocalBackendName = "local"
|
||||
|
||||
type Backend struct {
|
||||
Default string `yaml:"default"` // The default backend to use (defaults to the internal backend)
|
||||
|
||||
Mikrotik []BackendMikrotik `yaml:"mikrotik"`
|
||||
}
|
||||
|
||||
// Validate checks the backend configuration for errors.
|
||||
func (b *Backend) Validate() error {
|
||||
if b.Default == "" {
|
||||
b.Default = LocalBackendName
|
||||
}
|
||||
|
||||
uniqueMap := make(map[string]struct{})
|
||||
for _, backend := range b.Mikrotik {
|
||||
if backend.Id == LocalBackendName {
|
||||
return fmt.Errorf("backend ID %q is a reserved keyword", LocalBackendName)
|
||||
}
|
||||
if _, exists := uniqueMap[backend.Id]; exists {
|
||||
return fmt.Errorf("backend ID %q is not unique", backend.Id)
|
||||
}
|
||||
uniqueMap[backend.Id] = struct{}{}
|
||||
}
|
||||
|
||||
if b.Default != LocalBackendName {
|
||||
if _, ok := uniqueMap[b.Default]; !ok {
|
||||
return fmt.Errorf("default backend %q is not defined in the configuration", b.Default)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type BackendBase struct {
|
||||
Id string `yaml:"id"` // A unique id for the backend
|
||||
DisplayName string `yaml:"display_name"` // A display name for the backend
|
||||
}
|
||||
|
||||
// GetDisplayName returns the display name of the backend.
|
||||
// If no display name is set, it falls back to the ID.
|
||||
func (b BackendBase) GetDisplayName() string {
|
||||
if b.DisplayName == "" {
|
||||
return b.Id // Fallback to ID if no display name is set
|
||||
}
|
||||
return b.DisplayName
|
||||
}
|
||||
|
||||
type BackendMikrotik struct {
|
||||
BackendBase `yaml:",inline"` // Embed the base fields
|
||||
|
||||
ApiUrl string `yaml:"api_url"` // The base URL of the Mikrotik API (e.g., "https://10.10.10.10:8729/rest")
|
||||
ApiUser string `yaml:"api_user"`
|
||||
ApiPassword string `yaml:"api_password"`
|
||||
ApiVerifyTls bool `yaml:"api_verify_tls"` // Whether to verify the TLS certificate of the Mikrotik API
|
||||
ApiTimeout time.Duration `yaml:"api_timeout"` // Timeout for API requests (default: 30 seconds)
|
||||
|
||||
// Concurrency controls the maximum number of concurrent API requests that this backend will issue
|
||||
// when enumerating interfaces and their details. If 0 or negative, a default of 5 is used.
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
|
||||
Debug bool `yaml:"debug"` // Enable debug logging for the Mikrotik backend
|
||||
}
|
||||
|
||||
// GetConcurrency returns the configured concurrency for this backend or a sane default (5)
|
||||
// when the configured value is zero or negative.
|
||||
func (b *BackendMikrotik) GetConcurrency() int {
|
||||
if b == nil {
|
||||
return 5
|
||||
}
|
||||
if b.Concurrency <= 0 {
|
||||
return 5
|
||||
}
|
||||
return b.Concurrency
|
||||
}
|
||||
|
||||
// GetApiTimeout returns the configured API timeout or a sane default (30 seconds)
|
||||
// when the configured value is zero or negative.
|
||||
func (b *BackendMikrotik) GetApiTimeout() time.Duration {
|
||||
if b == nil {
|
||||
return 30 * time.Second
|
||||
}
|
||||
if b.ApiTimeout <= 0 {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return b.ApiTimeout
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user