Compare commits

...

22 Commits

Author SHA1 Message Date
Christoph Haas
12390a523a ensure that LDAP filter values are escaped 2025-09-03 19:02:17 +02:00
dependabot[bot]
41cab5f7ea chore(deps): bump the actions group with 2 updates (#499)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
Bumps the actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `actions/checkout` from 4 to 5
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

Updates `actions/download-artifact` from 4 to 5
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-12 21:47:25 +02:00
dependabot[bot]
708c558211 chore(deps): bump github.com/go-pkgz/routegroup in the patch group (#498)
Bumps the patch group with 1 update: [github.com/go-pkgz/routegroup](https://github.com/go-pkgz/routegroup).


Updates `github.com/go-pkgz/routegroup` from 1.5.1 to 1.5.2
- [Release notes](https://github.com/go-pkgz/routegroup/releases)
- [Commits](https://github.com/go-pkgz/routegroup/compare/v1.5.1...v1.5.2)

---
updated-dependencies:
- dependency-name: github.com/go-pkgz/routegroup
  dependency-version: 1.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-12 21:47:15 +02:00
h44z
99df4ca3cd ensure hooks run after restart (#494) (#497) 2025-08-12 21:47:04 +02:00
h44z
9884d8c002 fix migration tool (#495) (#496)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
2025-08-11 19:05:33 +02:00
Christoph Haas
b099e8abfa ensure that v2 (or just 2) tags are only published for stable releases (#493) 2025-08-11 18:11:32 +02:00
h44z
112f6bfb77 Mikrotik integration (#467)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
Allow MikroTik routes as WireGuard backends
2025-08-10 14:42:02 +02:00
Christoph Haas
a86f83a219 ensure that deleted peers are restored once the interface is re-enabled 2025-08-10 14:18:43 +02:00
Christoph Haas
131413b470 chore: update routegroup dependency 2025-08-10 10:59:28 +02:00
Christoph Haas
2246829151 chore: update deps (except routegroup, it breaks the api)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
2025-08-09 15:56:02 +02:00
Christoph Haas
c20f17cddf fix multi-peer generation, fix prefix handling (#491) 2025-08-09 15:55:29 +02:00
Marcin Woźniak
3f76aa416f chore(logs): added more debug logs and reformated those files using gofmt (#490)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-07-27 23:32:34 +02:00
h44z
6a8b28df88 add dark mode (#482) (#489)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
2025-07-27 00:15:27 +02:00
dependabot[bot]
ffef1f7b12 chore(deps): bump gorm.io/driver/sqlserver in the gorm group (#488)
Bumps the gorm group with 1 update: [gorm.io/driver/sqlserver](https://github.com/go-gorm/sqlserver).


Updates `gorm.io/driver/sqlserver` from 1.6.0 to 1.6.1
- [Commits](https://github.com/go-gorm/sqlserver/compare/v1.6.0...v1.6.1)

---
updated-dependencies:
- dependency-name: gorm.io/driver/sqlserver
  dependency-version: 1.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: gorm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-26 22:28:52 +02:00
dependabot[bot]
dc002b156b chore(deps): bump github.com/alexedwards/scs/v2 from 2.8.0 to 2.9.0 (#487)
Bumps [github.com/alexedwards/scs/v2](https://github.com/alexedwards/scs) from 2.8.0 to 2.9.0.
- [Release notes](https://github.com/alexedwards/scs/releases)
- [Commits](https://github.com/alexedwards/scs/compare/v2.8.0...v2.9.0)

---
updated-dependencies:
- dependency-name: github.com/alexedwards/scs/v2
  dependency-version: 2.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-26 12:58:07 +02:00
Christoph Haas
1794b8653a add retry handling for auth provider setup (#484)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-07-19 23:29:05 +02:00
dependabot[bot]
a6d985d3ce chore(deps): bump the patch group across 1 directory with 4 updates (#485)
Bumps the patch group with 2 updates in the / directory: [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) and [github.com/swaggo/swag](https://github.com/swaggo/swag).


Updates `github.com/go-webauthn/webauthn` from 0.13.0 to 0.13.4
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.13.0...v0.13.4)

Updates `github.com/swaggo/swag` from 1.16.4 to 1.16.5
- [Release notes](https://github.com/swaggo/swag/releases)
- [Changelog](https://github.com/swaggo/swag/blob/master/.goreleaser.yml)
- [Commits](https://github.com/swaggo/swag/compare/v1.16.4...v1.16.5)

Updates `golang.org/x/crypto` from 0.39.0 to 0.40.0
- [Commits](https://github.com/golang/crypto/compare/v0.39.0...v0.40.0)

Updates `golang.org/x/sys` from 0.33.0 to 0.34.0
- [Commits](https://github.com/golang/sys/compare/v0.33.0...v0.34.0)

---
updated-dependencies:
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.13.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch
- dependency-name: github.com/swaggo/swag
  dependency-version: 1.16.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch
- dependency-name: golang.org/x/crypto
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: patch
- dependency-name: golang.org/x/sys
  dependency-version: 0.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-19 22:36:11 +02:00
dependabot[bot]
a7bd3b3f95 chore(deps): bump github.com/go-playground/validator/v10 (#480)
Bumps [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) from 10.26.0 to 10.27.0.
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.26.0...v10.27.0)

---
updated-dependencies:
- dependency-name: github.com/go-playground/validator/v10
  dependency-version: 10.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-19 22:20:23 +02:00
h44z
f286840964 fix oauth domain check (#474) (#476)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-06-29 20:00:15 +02:00
h44z
edb88b5768 new webhook models (#444) (#471)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
warning: existing webhook receivers need to be adapted to the new models
2025-06-29 19:49:01 +02:00
h44z
588bbca141 only execute interface hooks if the state has changed (#469) (#472) 2025-06-29 19:48:46 +02:00
h44z
f08740991b support for raw-wireguard and wg-quick style peer configurations (#441) (#473) 2025-06-29 19:47:53 +02:00
80 changed files with 4376 additions and 608 deletions

View File

@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -60,7 +60,7 @@ jobs:
permissions:
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: docker/login-action@v3
with:

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -66,10 +66,6 @@ jobs:
type=semver,pattern={{major}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
# add v{{major}} tag, even for beta or release-canidate releases
type=match,pattern=(v\d),group=1,enable=${{ contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
# add {{major}} tag, even for beta releases or release-canidate releases
type=match,pattern=v(\d),group=1,enable=${{ contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
@@ -114,7 +110,7 @@ jobs:
contents: write
steps:
- name: Download binaries
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: binaries

View File

@@ -15,7 +15,7 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0

View File

@@ -32,6 +32,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos
* Docker ready
* Can be used with existing WireGuard setups
* Support for multiple WireGuard interfaces
* Supports multiple WireGuard backends (wgctrl or MikroTik [BETA])
* Peer Expiry Feature
* Handles route and DNS settings like wg-quick does
* Exposes Prometheus metrics for monitoring and alerting

View File

@@ -50,7 +50,8 @@ func main() {
database, err := adapters.NewSqlRepository(rawDb)
internal.AssertNoError(err)
wireGuard := adapters.NewWireGuardRepository()
wireGuard, err := wireguard.NewControllerManager(cfg)
internal.AssertNoError(err)
wgQuick := adapters.NewWgQuickRepo()
@@ -87,6 +88,7 @@ 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)
@@ -133,7 +135,7 @@ func main() {
apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers)
apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces)
apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers)
apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth)
apiV0EndpointConfig := handlersV0.NewConfigEndpoint(cfg, apiV0Auth, wireGuard)
apiV0EndpointTest := handlersV0.NewTestEndpoint(apiV0Auth)
apiFrontend := handlersV0.NewRestApi(apiV0Session,

View File

@@ -24,6 +24,9 @@ core:
self_provisioning_allowed: false
import_existing: true
restore_state: true
backend:
default: local
advanced:
log_level: info
@@ -102,6 +105,7 @@ webhook:
Below you will find sections like
[`core`](#core),
[`backend`](#backend),
[`advanced`](#advanced),
[`database`](#database),
[`statistics`](#statistics),
@@ -165,6 +169,65 @@ 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.
@@ -673,19 +736,6 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
## Webhook
The webhook section allows you to configure a webhook that is called on certain events in WireGuard Portal.
A JSON object is sent in a POST request to the webhook URL with the following structure:
```json
{
"event": "update",
"entity": "peer",
"identifier": "the-peer-identifier",
"payload": {
// The payload of the event, e.g. peer data.
// Check the API documentation for the exact structure.
}
}
```
Further details can be found in the [usage documentation](../usage/webhooks.md).
### `url`

View File

@@ -0,0 +1,57 @@
# Backends
WireGuard Portal can manage WireGuard interfaces and peers on different backends.
Each backend represents a system where interfaces actually live.
You can register multiple backends and choose which one to use per interface.
A global default backend determines where newly created interfaces go (unless you explicitly choose another in the UI).
**Supported backends:**
- **Local** (default): Manages interfaces on the host running WireGuard Portal (Linux WireGuard via wgctrl). Use this when the portal should directly configure wg devices on the same server.
- **MikroTik** RouterOS (_beta_): Manages interfaces and peers on MikroTik devices via the RouterOS REST API. Use this to control WG interfaces on RouterOS v7+.
How backend selection works:
- The default backend is configured at `backend.default` (_local_ or the id of a defined MikroTik backend).
New interfaces created in the UI will use this backend by default.
- Each interface stores its backend. You can select a different backend when creating a new interface.
## Configuring MikroTik backends (RouterOS v7+)
> :warning: The MikroTik backend is currently marked beta. While basic functionality is implemented, some advanced features are not yet implemented or contain bugs. Please test carefully before using in production.
The MikroTik backend uses the [REST API](https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API) under a base URL ending with /rest.
You can register one or more MikroTik devices as backends for a single WireGuard Portal instance.
### Prerequisites on MikroTik:
- RouterOS v7 with WireGuard support.
- REST API enabled and reachable over HTTP(S). A typical base URL is https://<router-address>:8729/rest or https://<router-address>/rest depending on your service setup.
- A dedicated RouterOS user with the following group permissions:
- **api** (for logging in via REST API)
- **rest-api** (for logging in via REST API)
- **read** (to read interface and peer data)
- **write** (to create/update interfaces and peers)
- **test** (to perform ping checks)
- **sensitive** (to read private keys)
- TLS certificate on the device is recommended. If you use a self-signed certificate during testing, set `api_verify_tls`: _false_ in wg-portal (not recommended for production).
Example WireGuard Portal configuration (config/config.yaml):
```yaml
backend:
# default backend decides where new interfaces are created
default: mikrotik-prod
mikrotik:
- id: mikrotik-prod # unique id, not "local"
display_name: RouterOS RB5009 # optional nice name
api_url: https://10.10.10.10/rest
api_user: wgportal
api_password: a-super-secret-password
api_verify_tls: true # set to false only if using self-signed during testing
api_timeout: 30s # maximum request duration
concurrency: 5 # limit parallel REST calls to device
debug: false # verbose logging for this backend
```
### Known limitations:
- The MikroTik backend is still in beta. Some features may not work as expected.
- Not all WireGuard Portal features are supported yet (e.g., no support for interface hooks)

View File

@@ -38,11 +38,12 @@ WireGuard Portal supports various events that can trigger webhooks. The followin
- `connect`: Triggered when a user connects to the VPN.
- `disconnect`: Triggered when a user disconnects from the VPN.
The following entity types can trigger webhooks:
The following entity models are supported for webhook events:
- `user`: When a WireGuard Portal user is created, updated, or deleted.
- `peer`: When a peer is created, updated, or deleted. This entity can also trigger `connect` and `disconnect` events.
- `interface`: When a device is created, updated, or deleted.
- `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
@@ -51,36 +52,234 @@ A common shell structure for webhook payloads is as follows:
```json
{
"event": "create",
"entity": "user",
"identifier": "the-user-identifier",
"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. peer data.
// Check the API documentation for the exact structure.
// The payload of the event, e.g. a Peer model.
// Detailed model descriptions are provided below.
}
}
```
### Payload Models
### Example Payload
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": {
"PeerId": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
"IsConnected": true,
"IsPingable": false,
"LastPing": null,
"BytesReceived": 1860,
"BytesTransmitted": 10824,
"LastHandshake": "2025-06-26T23:04:33.325216659+02:00",
"Endpoint": "10.55.66.77:33874",
"LastSessionStart": "2025-06-26T22:50:40.10221606+02:00"
"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
}
}
```

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-bs-theme="light">
<head>
<meta charset="UTF-8" />
<link href="/favicon.ico" rel="icon" />

View File

@@ -14,8 +14,8 @@
"@popperjs/core": "^2.11.8",
"@simplewebauthn/browser": "^13.1.0",
"@vojtechlanka/vue-tags-input": "^3.1.1",
"bootstrap": "^5.3.5",
"bootswatch": "^5.3.5",
"bootstrap": "^5.3.7",
"bootswatch": "^5.3.7",
"flag-icons": "^7.3.2",
"ip-address": "^10.0.1",
"is-cidr": "^5.1.1",
@@ -1048,9 +1048,9 @@
}
},
"node_modules/bootstrap": {
"version": "5.3.5",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.5.tgz",
"integrity": "sha512-ct1CHKtiobRimyGzmsSldEtM03E8fcEX4Tb3dGXz1V8faRwM50+vfHwTzOxB3IlKO7m+9vTH3s/3C6T2EAPeTA==",
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz",
"integrity": "sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==",
"funding": [
{
"type": "github",
@@ -1067,9 +1067,9 @@
}
},
"node_modules/bootswatch": {
"version": "5.3.5",
"resolved": "https://registry.npmjs.org/bootswatch/-/bootswatch-5.3.5.tgz",
"integrity": "sha512-1z8LNoUL5NHmv/hNROALQ6qtjw9OJIjMgP8ovBlIft+oI15b/mvnzxGL896iO9LtoDZH0Vdm+D2YW+j03GduSg==",
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/bootswatch/-/bootswatch-5.3.7.tgz",
"integrity": "sha512-n0X99+Jmpmd4vgkli5KwMOuAkgdyUPhq7cIAwoGXbM6WhE/mmkWACfxpr7WZeG9Pdx509Ndi+2K1HlzXXOr8/Q==",
"license": "MIT"
},
"node_modules/buffer-builder": {

View File

@@ -14,8 +14,8 @@
"@popperjs/core": "^2.11.8",
"@simplewebauthn/browser": "^13.1.0",
"@vojtechlanka/vue-tags-input": "^3.1.1",
"bootstrap": "^5.3.5",
"bootswatch": "^5.3.5",
"bootstrap": "^5.3.7",
"bootswatch": "^5.3.7",
"flag-icons": "^7.3.2",
"ip-address": "^10.0.1",
"is-cidr": "^5.1.1",

View File

@@ -14,6 +14,10 @@ const settings = settingsStore()
onMounted(async () => {
console.log("Starting WireGuard Portal frontend...");
// restore theme from localStorage
const theme = localStorage.getItem('wgTheme') || 'light';
document.documentElement.setAttribute('data-bs-theme', theme);
await sec.LoadSecurityProperties();
await auth.LoadProviders();
@@ -40,6 +44,13 @@ const switchLanguage = function (lang) {
}
}
const switchTheme = function (theme) {
if (document.documentElement.getAttribute('data-bs-theme') !== theme) {
localStorage.setItem('wgTheme', theme);
document.documentElement.setAttribute('data-bs-theme', theme);
}
}
const languageFlag = computed(() => {
// `this` points to the component instance
let lang = appGlobal.$i18n.locale.toLowerCase();
@@ -125,6 +136,24 @@ 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>
@@ -141,7 +170,7 @@ const userDisplayName = computed(() => {
<div class="col-6 text-end">
<div :aria-label="$t('menu.lang')" class="btn-group" role="group">
<div class="btn-group" role="group">
<button aria-expanded="false" aria-haspopup="true" class="btn btn btn-secondary pe-0"
<button aria-expanded="false" aria-haspopup="true" class="btn flag-button pe-0"
data-bs-toggle="dropdown" type="button"><span :class="languageFlag" class="fi"></span></button>
<div aria-labelledby="btnGroupDrop3" class="dropdown-menu" style="">
<a class="dropdown-item" href="#" @click.prevent="switchLanguage('de')"><span class="fi fi-de"></span> Deutsch</a>
@@ -163,4 +192,31 @@ const userDisplayName = computed(() => {
</footer>
</template>
<style></style>
<style>
.flag-button:active,.flag-button:hover,.flag-button:focus,.flag-button:checked,.flag-button:disabled,.flag-button:not(:disabled) {
border: 1px solid transparent!important;
}
[data-bs-theme=dark] .form-select {
color: #0c0c0c!important;
background-color: #c1c1c1!important;
--bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")!important;
}
[data-bs-theme=dark] .form-control {
color: #0c0c0c!important;
background-color: #c1c1c1!important;
}
[data-bs-theme=dark] .form-control:focus {
color: #0c0c0c!important;
background-color: #c1c1c1!important;
}
[data-bs-theme=dark] .badge.bg-light {
--bs-bg-opacity: 1;
background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important;
color: var(--bs-badge-color)!important;
}
[data-bs-theme=dark] span.input-group-text {
--bs-bg-opacity: 1;
background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important;
color: var(--bs-badge-color)!important;
}
</style>

View File

@@ -65,6 +65,14 @@ a.disabled {
color: var(--bs-body-color);
}
[data-bs-theme=dark] .vue-tags-input .ti-tag {
position: relative;
background: #3c3c3c;
border: 2px solid var(--bs-body-color);
margin: 6px;
color: var(--bs-body-color);
}
/* the styles if a tag is invalid */
.vue-tags-input .ti-tag.ti-invalid {
background-color: #e88a74;

View File

@@ -10,11 +10,13 @@ import isCidr from "is-cidr";
import {isIP} from 'is-ip';
import { freshInterface } from '@/helpers/models';
import {peerStore} from "@/stores/peers";
import {settingsStore} from "@/stores/settings";
const { t } = useI18n()
const interfaces = interfaceStore()
const peers = peerStore()
const settings = settingsStore()
const props = defineProps({
interfaceId: String,
@@ -48,6 +50,26 @@ const currentTags = ref({
PeerDefDnsSearch: ""
})
const formData = ref(freshInterface())
const isSaving = ref(false)
const isDeleting = ref(false)
const isApplyingDefaults = ref(false)
const isBackendValid = computed(() => {
if (!props.visible || !selectedInterface.value) {
return true // if modal is not visible or no interface is selected, we don't care about backend validity
}
let backendId = selectedInterface.value.Backend
let valid = false
let availableBackends = settings.Setting('AvailableBackends') || []
availableBackends.forEach(backend => {
if (backend.Id === backendId) {
valid = true
}
})
return valid
})
// functions
@@ -61,6 +83,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
formData.value.Identifier = interfaces.Prepared.Identifier
formData.value.DisplayName = interfaces.Prepared.DisplayName
formData.value.Mode = interfaces.Prepared.Mode
formData.value.Backend = interfaces.Prepared.Backend
formData.value.PublicKey = interfaces.Prepared.PublicKey
formData.value.PrivateKey = interfaces.Prepared.PrivateKey
@@ -99,6 +122,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
formData.value.Identifier = selectedInterface.value.Identifier
formData.value.DisplayName = selectedInterface.value.DisplayName
formData.value.Mode = selectedInterface.value.Mode
formData.value.Backend = selectedInterface.value.Backend
formData.value.PublicKey = selectedInterface.value.PublicKey
formData.value.PrivateKey = selectedInterface.value.PrivateKey
@@ -237,6 +261,8 @@ function handleChangePeerDefDnsSearch(tags) {
}
async function save() {
if (isSaving.value) return
isSaving.value = true
try {
if (props.interfaceId!=='#NEW#') {
await interfaces.UpdateInterface(selectedInterface.value.Identifier, formData.value)
@@ -251,6 +277,8 @@ async function save() {
text: e.toString(),
type: 'error',
})
} finally {
isSaving.value = false
}
}
@@ -259,6 +287,8 @@ async function applyPeerDefaults() {
return; // do nothing for new interfaces
}
if (isApplyingDefaults.value) return
isApplyingDefaults.value = true
try {
await interfaces.ApplyPeerDefaults(selectedInterface.value.Identifier, formData.value)
@@ -276,10 +306,14 @@ async function applyPeerDefaults() {
text: e.toString(),
type: 'error',
})
} finally {
isApplyingDefaults.value = false
}
}
async function del() {
if (isDeleting.value) return
isDeleting.value = true
try {
await interfaces.DeleteInterface(selectedInterface.value.Identifier)
close()
@@ -290,6 +324,8 @@ async function del() {
text: e.toString(),
type: 'error',
})
} finally {
isDeleting.value = false
}
}
@@ -314,13 +350,22 @@ async function del() {
<label class="form-label mt-4">{{ $t('modals.interface-edit.identifier.label') }}</label>
<input v-model="formData.Identifier" class="form-control" :placeholder="$t('modals.interface-edit.identifier.placeholder')" type="text">
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.mode.label') }}</label>
<select v-model="formData.Mode" class="form-select">
<option value="server">{{ $t('modals.interface-edit.mode.server') }}</option>
<option value="client">{{ $t('modals.interface-edit.mode.client') }}</option>
<option value="any">{{ $t('modals.interface-edit.mode.any') }}</option>
</select>
<div class="row">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.mode.label') }}</label>
<select v-model="formData.Mode" class="form-select">
<option value="server">{{ $t('modals.interface-edit.mode.server') }}</option>
<option value="client">{{ $t('modals.interface-edit.mode.client') }}</option>
<option value="any">{{ $t('modals.interface-edit.mode.any') }}</option>
</select>
</div>
<div class="form-group col-md-6">
<label class="form-label mt-4" for="ifaceBackendSelector">{{ $t('modals.interface-edit.backend.label') }}</label>
<select id="ifaceBackendSelector" v-model="formData.Backend" class="form-select" aria-describedby="backendHelp">
<option v-for="backend in settings.Setting('AvailableBackends')" :value="backend.Id">{{ backend.Id === 'local' ? $t(backend.Name) : backend.Name }}</option>
</select>
<small v-if="!isBackendValid" id="backendHelp" class="form-text text-warning">{{ $t('modals.interface-edit.backend.invalid-label') }}</small>
</div>
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.interface-edit.display-name.label') }}</label>
@@ -385,12 +430,14 @@ async function del() {
<label class="form-label mt-4">{{ $t('modals.interface-edit.mtu.label') }}</label>
<input v-model="formData.Mtu" class="form-control" :placeholder="$t('modals.interface-edit.mtu.placeholder')" type="number">
</div>
<div class="form-group col-md-6">
<div class="form-group col-md-6" v-if="formData.Backend==='local'">
<label class="form-label mt-4">{{ $t('modals.interface-edit.firewall-mark.label') }}</label>
<input v-model="formData.FirewallMark" class="form-control" :placeholder="$t('modals.interface-edit.firewall-mark.placeholder')" type="number">
</div>
<div class="form-group col-md-6" v-else>
</div>
</div>
<div class="row">
<div class="row" v-if="formData.Backend==='local'">
<div class="form-group col-md-6">
<label class="form-label mt-4">{{ $t('modals.interface-edit.routing-table.label') }}</label>
<input v-model="formData.RoutingTable" aria-describedby="routingTableHelp" class="form-control" :placeholder="$t('modals.interface-edit.routing-table.placeholder')" type="text">
@@ -530,16 +577,25 @@ async function del() {
</fieldset>
<fieldset v-if="props.interfaceId!=='#NEW#'" class="text-end">
<hr class="mt-4">
<button class="btn btn-primary me-1" type="button" @click.prevent="applyPeerDefaults">{{ $t('modals.interface-edit.button-apply-defaults') }}</button>
<button class="btn btn-primary me-1" type="button" @click.prevent="applyPeerDefaults" :disabled="isApplyingDefaults">
<span v-if="isApplyingDefaults" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
{{ $t('modals.interface-edit.button-apply-defaults') }}
</button>
</fieldset>
</div>
</div>
</template>
<template #footer>
<div class="flex-fill text-start">
<button v-if="props.interfaceId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
<button v-if="props.interfaceId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del" :disabled="isDeleting">
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
{{ $t('general.delete') }}
</button>
</div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-primary me-1" type="button" @click.prevent="save" :disabled="isSaving">
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
{{ $t('general.save') }}
</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>

View File

@@ -73,6 +73,8 @@ const currentTags = ref({
DnsSearch: ""
})
const formData = ref(freshPeer())
const isSaving = ref(false)
const isDeleting = ref(false)
// functions
@@ -270,6 +272,8 @@ function handleChangeDnsSearch(tags) {
}
async function save() {
if (isSaving.value) return
isSaving.value = true
try {
if (props.peerId !== '#NEW#') {
await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value)
@@ -278,26 +282,30 @@ async function save() {
}
close()
} catch (e) {
// console.log(e)
notify({
title: "Failed to save peer!",
text: e.toString(),
type: 'error',
})
} finally {
isSaving.value = false
}
}
async function del() {
if (isDeleting.value) return
isDeleting.value = true
try {
await peers.DeletePeer(selectedPeer.value.Identifier)
close()
} catch (e) {
// console.log(e)
notify({
title: "Failed to delete peer!",
text: e.toString(),
type: 'error',
})
} finally {
isDeleting.value = false
}
}
@@ -470,10 +478,15 @@ async function del() {
</template>
<template #footer>
<div class="flex-fill text-start">
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{
$t('general.delete') }}</button>
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del" :disabled="isDeleting">
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
{{ $t('general.delete') }}
</button>
</div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-primary me-1" type="button" @click.prevent="save" :disabled="isSaving">
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
{{ $t('general.save') }}
</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>

View File

@@ -32,12 +32,13 @@ const selectedInterface = computed(() => {
function freshForm() {
return {
Identifiers: [],
Suffix: "",
Prefix: "",
}
}
const currentTag = ref("")
const formData = ref(freshForm())
const isSaving = ref(false)
const title = computed(() => {
if (!props.visible) {
@@ -60,12 +61,15 @@ function handleChangeUserIdentifiers(tags) {
}
async function save() {
if (isSaving.value) return
isSaving.value = true
if (formData.value.Identifiers.length === 0) {
notify({
title: "Missing Identifiers",
text: "At least one identifier is required to create a new peer.",
type: 'error',
})
isSaving.value = false
return
}
@@ -79,6 +83,8 @@ async function save() {
text: e.toString(),
type: 'error',
})
} finally {
isSaving.value = false
}
}
@@ -102,13 +108,16 @@ async function save() {
</div>
<div class="form-group">
<label class="form-label mt-4">{{ $t('modals.peer-multi-create.prefix.label') }}</label>
<input type="text" class="form-control" :placeholder="$t('modals.peer-multi-create.prefix.placeholder')" v-model="formData.Suffix">
<input type="text" class="form-control" :placeholder="$t('modals.peer-multi-create.prefix.placeholder')" v-model="formData.Prefix">
<small class="form-text text-muted">{{ $t('modals.peer-multi-create.prefix.description') }}</small>
</div>
</fieldset>
</template>
<template #footer>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-primary me-1" type="button" @click.prevent="save" :disabled="isSaving">
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
{{ $t('general.save') }}
</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>

View File

@@ -50,7 +50,7 @@ const selectedStats = computed(() => {
if (!s) {
if (!!props.peerId || props.peerId.length) {
p = profile.Statistics(props.peerId)
s = profile.Statistics(props.peerId)
} else {
s = freshStats() // dummy stats to avoid 'undefined' exceptions
}
@@ -79,13 +79,19 @@ const title = computed(() => {
}
})
const configStyle = ref("wgquick")
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
await peers.LoadPeerConfig(selectedPeer.value.Identifier)
await peers.LoadPeerConfig(selectedPeer.value.Identifier, configStyle.value)
configString.value = peers.configuration
}
}
)
})
watch(() => configStyle.value, async () => {
await peers.LoadPeerConfig(selectedPeer.value.Identifier, configStyle.value)
configString.value = peers.configuration
})
function download() {
// credit: https://www.bitdegree.org/learn/javascript-download
@@ -103,7 +109,7 @@ function download() {
}
function email() {
peers.MailPeerConfig(settings.Setting("MailLinkOnly"), [selectedPeer.value.Identifier]).catch(e => {
peers.MailPeerConfig(settings.Setting("MailLinkOnly"), configStyle.value, [selectedPeer.value.Identifier]).catch(e => {
notify({
title: "Failed to send mail with peer configuration!",
text: e.toString(),
@@ -114,7 +120,7 @@ function email() {
function ConfigQrUrl() {
if (props.peerId.length) {
return apiWrapper.url(`/peer/config-qr/${base64_url_encode(props.peerId)}`)
return apiWrapper.url(`/peer/config-qr/${base64_url_encode(props.peerId)}?style=${configStyle.value}`)
}
return ''
}
@@ -124,6 +130,15 @@ function ConfigQrUrl() {
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<div class="d-flex justify-content-end align-items-center mb-1">
<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">
@@ -213,6 +228,14 @@ function ConfigQrUrl() {
</template>
</Modal></template>
<style>.config-qr-img {
<style>
.config-qr-img {
max-width: 100%;
}</style>
}
.btn-switch-group .btn {
border-width: 1px;
padding: 5px;
line-height: 1;
}
</style>

View File

@@ -34,6 +34,8 @@ 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')
@@ -89,6 +91,8 @@ function close() {
}
async function save() {
if (isSaving.value) return
isSaving.value = true
try {
if (props.userId!=='#NEW#') {
await users.UpdateUser(selectedUser.value.Identifier, formData.value)
@@ -102,10 +106,14 @@ async function save() {
text: e.toString(),
type: 'error',
})
} finally {
isSaving.value = false
}
}
async function del() {
if (isDeleting.value) return
isDeleting.value = true
try {
await users.DeleteUser(selectedUser.value.Identifier)
close()
@@ -115,6 +123,8 @@ async function del() {
text: e.toString(),
type: 'error',
})
} finally {
isDeleting.value = false
}
}
@@ -193,9 +203,15 @@ async function del() {
</template>
<template #footer>
<div class="flex-fill text-start">
<button v-if="props.userId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
<button v-if="props.userId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del" :disabled="isDeleting">
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
{{ $t('general.delete') }}
</button>
</div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save" :disabled="!formValid">{{ $t('general.save') }}</button>
<button class="btn btn-primary me-1" type="button" @click.prevent="save" :disabled="!formValid || isSaving">
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
{{ $t('general.save') }}
</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>

View File

@@ -55,6 +55,8 @@ const title = computed(() => {
})
const formData = ref(freshPeer())
const isSaving = ref(false)
const isDeleting = ref(false)
// functions
@@ -163,6 +165,8 @@ function close() {
}
async function save() {
if (isSaving.value) return
isSaving.value = true
try {
if (props.peerId !== '#NEW#') {
await peers.UpdatePeer(selectedPeer.value.Identifier, formData.value)
@@ -171,26 +175,30 @@ async function save() {
}
close()
} catch (e) {
// console.log(e)
notify({
title: "Failed to save peer!",
text: e.toString(),
type: 'error',
})
} finally {
isSaving.value = false
}
}
async function del() {
if (isDeleting.value) return
isDeleting.value = true
try {
await peers.DeletePeer(selectedPeer.value.Identifier)
close()
} catch (e) {
// console.log(e)
notify({
title: "Failed to delete peer!",
text: e.toString(),
type: 'error',
})
} finally {
isDeleting.value = false
}
}
@@ -283,10 +291,15 @@ async function del() {
</template>
<template #footer>
<div class="flex-fill text-start">
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{
$t('general.delete') }}</button>
<button v-if="props.peerId !== '#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del" :disabled="isDeleting">
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
{{ $t('general.delete') }}
</button>
</div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-primary me-1" type="button" @click.prevent="save" :disabled="isSaving">
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
{{ $t('general.save') }}
</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>

View File

@@ -5,6 +5,7 @@ export function freshInterface() {
DisplayName: "",
Identifier: "",
Mode: "server",
Backend: "local",
PublicKey: "",
PrivateKey: "",

View File

@@ -102,7 +102,9 @@
},
"interface": {
"headline": "Schnittstellenstatus für",
"mode": "Modus",
"backend": "Backend",
"unknown-backend": "Unbekannt",
"wrong-backend": "Ungültiges Backend, das lokale WireGuard Backend wird stattdessen verwendet!",
"key": "Öffentlicher Schlüssel",
"endpoint": "Öffentlicher Endpunkt",
"port": "Port",
@@ -357,6 +359,11 @@
"client": "Client-Modus",
"any": "Unbekannter Modus"
},
"backend": {
"label": "Schnittstellenbackend",
"invalid-label": "Ursprüngliches Backend ist ungültig, das lokale WireGuard Backend wird stattdessen verwendet!",
"local": "Lokales WireGuard Backend"
},
"display-name": {
"label": "Anzeigename",
"placeholder": "Der beschreibende Name für die Schnittstelle"
@@ -467,7 +474,8 @@
"connected-since": "Verbunden seit",
"endpoint": "Endpunkt",
"button-download": "Konfiguration herunterladen",
"button-email": "Konfiguration per E-Mail senden"
"button-email": "Konfiguration per E-Mail senden",
"style-label": "Konfigurationsformat"
},
"peer-edit": {
"headline-edit-peer": "Peer bearbeiten:",

View File

@@ -102,7 +102,9 @@
},
"interface": {
"headline": "Interface status for",
"mode": "mode",
"backend": "Backend",
"unknown-backend": "Unknown",
"wrong-backend": "Invalid backend, using local WireGuard backend instead!",
"key": "Public Key",
"endpoint": "Public Endpoint",
"port": "Listening Port",
@@ -357,6 +359,11 @@
"client": "Client Mode",
"any": "Unknown Mode"
},
"backend": {
"label": "Interface Backend",
"invalid-label": "Original backend is no longer available, using local WireGuard backend instead!",
"local": "Local WireGuard Backend"
},
"display-name": {
"label": "Display Name",
"placeholder": "The descriptive name for the interface"
@@ -468,7 +475,8 @@
"connected-since": "Connected since",
"endpoint": "Endpoint",
"button-download": "Download configuration",
"button-email": "Send configuration via E-Mail"
"button-email": "Send configuration via E-Mail",
"style-label": "Configuration Style"
},
"peer-edit": {
"headline-edit-peer": "Edit peer:",

View File

@@ -99,7 +99,7 @@
},
"interface": {
"headline": "État de l'interface pour",
"mode": "mode",
"backend": "backend",
"key": "Clé publique",
"endpoint": "Point de terminaison public",
"port": "Port d'écoute",

View File

@@ -100,7 +100,7 @@
},
"interface": {
"headline": "인터페이스 상태:",
"mode": "드",
"backend": "백엔드",
"key": "공개 키",
"endpoint": "공개 엔드포인트",
"port": "수신 포트",

View File

@@ -101,7 +101,7 @@
},
"interface": {
"headline": "Status da interface para",
"mode": "modo",
"mode": "backend",
"key": "Chave Pública",
"endpoint": "Endpoint Público",
"port": "Porta de Escuta",

View File

@@ -99,7 +99,7 @@
},
"interface": {
"headline": "Статус интерфейса для",
"mode": "режим",
"backend": "бэкэнд",
"key": "Публичный ключ",
"endpoint": "Публичная конечная точка",
"port": "Порт прослушивания",

View File

@@ -99,7 +99,7 @@
},
"interface": {
"headline": "Статус інтерфейсу для",
"mode": "режим",
"backend": "бекенд",
"key": "Публічний ключ",
"endpoint": "Публічна кінцева точка",
"port": "Порт прослуховування",

View File

@@ -98,7 +98,7 @@
},
"interface": {
"headline": "Trạng thái giao diện cho",
"mode": "chế độ",
"backend": "phần sau",
"key": "Khóa Công khai",
"endpoint": "Điểm cuối Công khai",
"port": "Cổng Nghe",

View File

@@ -98,7 +98,7 @@
},
"interface": {
"headline": "接口状态",
"mode": "模式",
"backend": "后端",
"key": "公钥",
"endpoint": "公开节点",
"port": "监听端口",

View File

@@ -142,8 +142,8 @@ export const peerStore = defineStore('peers', {
})
})
},
async MailPeerConfig(linkOnly, ids) {
return apiWrapper.post(`${baseUrl}/config-mail`, {
async MailPeerConfig(linkOnly, style, ids) {
return apiWrapper.post(`${baseUrl}/config-mail?style=${style}`, {
Identifiers: ids,
LinkOnly: linkOnly
})
@@ -158,8 +158,8 @@ export const peerStore = defineStore('peers', {
throw new Error(error)
})
},
async LoadPeerConfig(id) {
return apiWrapper.get(`${baseUrl}/config/${base64_url_encode(id)}`)
async LoadPeerConfig(id, style) {
return apiWrapper.get(`${baseUrl}/config/${base64_url_encode(id)}?style=${style}`)
.then(this.setPeerConfig)
.catch(error => {
this.configuration = ""

View File

@@ -26,7 +26,7 @@ onMounted(async () => {
<div class="form-group d-inline">
<div class="input-group mb-3">
<input v-model="audit.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="audit.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
<button class="btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
</div>
</div>
</div>

View File

@@ -13,21 +13,21 @@ const auth = authStore()
<p class="lead">{{ $t('home.abstract') }}</p>
<div class="bg-light p-5" v-if="auth.IsAuthenticated">
<div class="card border-secondary p-5" v-if="auth.IsAuthenticated">
<h2 class="display-5">{{ $t('home.profiles.headline') }}</h2>
<p class="lead">{{ $t('home.profiles.abstract') }}</p>
<hr class="my-4">
<p>{{ $t('home.profiles.content') }}</p>
<p class="card-text">{{ $t('home.profiles.content') }}</p>
<p class="lead">
<RouterLink :to="{ name: 'profile' }" class="btn btn-primary btn-lg">{{ $t('home.profiles.button') }}</RouterLink>
</p>
</div>
<div class="bg-light p-5 mt-4" v-if="auth.IsAuthenticated && auth.IsAdmin">
<div class="card border-secondary p-5 mt-4" v-if="auth.IsAuthenticated && auth.IsAdmin">
<h2 class="display-5">{{ $t('home.admin.headline') }}</h2>
<p class="lead">{{ $t('home.admin.abstract') }}</p>
<hr class="my-4">
<p>{{ $t('home.admin.content') }}</p>
<p class="card-text">{{ $t('home.admin.content') }}</p>
<p class="lead">
<RouterLink :to="{ name: 'interfaces' }" class="btn btn-primary btn-lg me-2">{{ $t('home.admin.button-admin') }}
</RouterLink>

View File

@@ -5,17 +5,20 @@ import PeerMultiCreateModal from "../components/PeerMultiCreateModal.vue";
import InterfaceEditModal from "../components/InterfaceEditModal.vue";
import InterfaceViewModal from "../components/InterfaceViewModal.vue";
import {onMounted, ref} from "vue";
import {computed, onMounted, ref} from "vue";
import {peerStore} from "@/stores/peers";
import {interfaceStore} from "@/stores/interfaces";
import {notify} from "@kyvg/vue3-notification";
import {settingsStore} from "@/stores/settings";
import {humanFileSize} from '@/helpers/utils';
import {useI18n} from "vue-i18n";
const settings = settingsStore()
const interfaces = interfaceStore()
const peers = peerStore()
const { t } = useI18n()
const viewedPeerId = ref("")
const editPeerId = ref("")
const multiCreatePeerId = ref("")
@@ -45,6 +48,33 @@ function calculateInterfaceName(id, name) {
return result
}
const calculateBackendName = computed(() => {
let backendId = interfaces.GetSelected.Backend
let backendName = t('interfaces.interface.unknown-backend')
let availableBackends = settings.Setting('AvailableBackends') || []
availableBackends.forEach(backend => {
if (backend.Id === backendId) {
backendName = backend.Id === 'local' ? t(backend.Name) : backend.Name
}
})
return backendName
})
const isBackendValid = computed(() => {
let backendId = interfaces.GetSelected.Backend
let valid = false
let availableBackends = settings.Setting('AvailableBackends') || []
availableBackends.forEach(backend => {
if (backend.Id === backendId) {
valid = true
}
})
return valid
})
async function download() {
await interfaces.LoadInterfaceConfig(interfaces.GetSelected.Identifier)
@@ -112,7 +142,7 @@ onMounted(async () => {
</div>
<div class="form-group">
<div class="input-group mb-3">
<button class="input-group-text btn btn-primary" :title="$t('interfaces.button-add-interface')" @click.prevent="editInterfaceId='#NEW#'">
<button class="btn btn-primary" :title="$t('interfaces.button-add-interface')" @click.prevent="editInterfaceId='#NEW#'">
<i class="fa-solid fa-plus-circle"></i>
</button>
<select v-model="interfaces.selected" :disabled="interfaces.Count===0" class="form-select" @change="() => { peers.LoadPeers(); peers.LoadStats() }">
@@ -141,7 +171,7 @@ onMounted(async () => {
<div class="card-header">
<div class="row">
<div class="col-12 col-lg-8">
{{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{interfaces.GetSelected.Mode}} {{ $t('interfaces.interface.mode') }})
{{ $t('interfaces.interface.headline') }} <strong>{{interfaces.GetSelected.Identifier}}</strong> ({{ $t('modals.interface-edit.mode.' + interfaces.GetSelected.Mode )}} | {{ $t('interfaces.interface.backend') + ": " + calculateBackendName }}<span v-if="!isBackendValid" :title="t('interfaces.interface.wrong-backend')" class="ms-1 me-1"><i class="fa-solid fa-triangle-exclamation"></i></span>)
<span v-if="interfaces.GetSelected.Disabled" class="text-danger"><i class="fa fa-circle-xmark" :title="interfaces.GetSelected.DisabledReason"></i></span>
</div>
<div class="col-12 col-lg-4 text-lg-end">
@@ -314,7 +344,7 @@ onMounted(async () => {
<div class="form-group d-inline">
<div class="input-group mb-3">
<input v-model="peers.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="peers.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
<button class="btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
</div>
</div>
</div>
@@ -429,3 +459,5 @@ onMounted(async () => {
</div>
</div>
</template>
<style>
</style>

View File

@@ -65,7 +65,7 @@ onMounted(async () => {
<div class="input-group mb-3">
<input v-model="profile.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text"
@keyup="profile.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i
<button class="btn btn-primary" :title="$t('general.search.button')"><i
class="fa-solid fa-search"></i></button>
</div>
</div>
@@ -73,7 +73,7 @@ onMounted(async () => {
<div class="col-12 col-lg-3 text-lg-end">
<div class="form-group" v-if="settings.Setting('SelfProvisioning')">
<div class="input-group mb-3">
<button class="input-group-text btn btn-primary" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId = '#NEW#'">
<button class="btn btn-primary" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId = '#NEW#'">
<i class="fa fa-plus me-1"></i><i class="fa fa-user"></i>
</button>
<select v-model="profile.selectedInterfaceId" :disabled="profile.CountInterfaces===0" class="form-select">

View File

@@ -44,7 +44,7 @@ async function saveRename(credential) {
<p class="lead">{{ $t('settings.abstract') }}</p>
<div v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')">
<div class="bg-light p-5" v-if="profile.user.ApiToken">
<div class="card border-secondary p-5" v-if="profile.user.ApiToken">
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
<p class="lead">{{ $t('settings.api.abstract') }}</p>
<hr class="my-4">
@@ -72,7 +72,7 @@ async function saveRename(credential) {
</div>
<div class="row mt-5">
<div class="col-6">
<button class="input-group-text btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching">
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching">
<i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }}
</button>
</div>
@@ -81,18 +81,18 @@ async function saveRename(credential) {
</div>
</div>
</div>
<div class="bg-light p-5" v-else>
<div class="card border-secondary p-5" v-else>
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
<p class="lead">{{ $t('settings.api.abstract') }}</p>
<hr class="my-4">
<p>{{ $t('settings.api.inactive-description') }}</p>
<button class="input-group-text btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching">
<button class="btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching">
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }}
</button>
</div>
</div>
<div class="bg-light p-5 mt-5" v-if="settings.Setting('WebAuthnEnabled')">
<div class="card border-secondary p-5 mt-5" v-if="settings.Setting('WebAuthnEnabled')">
<h2 class="display-7">{{ $t('settings.webauthn.headline') }}</h2>
<p class="lead">{{ $t('settings.webauthn.abstract') }}</p>
<hr class="my-4">
@@ -101,7 +101,7 @@ async function saveRename(credential) {
<div class="row">
<div class="col-6">
<button class="input-group-text btn btn-primary" :title="$t('settings.webauthn.button-register-text')" @click.prevent="auth.RegisterWebAuthn" :disabled="auth.isFetching">
<button class="btn btn-primary" :title="$t('settings.webauthn.button-register-text')" @click.prevent="auth.RegisterWebAuthn" :disabled="auth.isFetching">
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.webauthn.button-register-title') }}
</button>
</div>

View File

@@ -35,7 +35,7 @@ onMounted(() => {
<div class="form-group d-inline">
<div class="input-group mb-3">
<input v-model="users.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="users.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
<button class="btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
</div>
</div>
</div>

59
go.mod
View File

@@ -4,32 +4,32 @@ go 1.24.0
require (
github.com/a8m/envsubst v1.4.3
github.com/alexedwards/scs/v2 v2.8.0
github.com/coreos/go-oidc/v3 v3.14.1
github.com/alexedwards/scs/v2 v2.9.0
github.com/coreos/go-oidc/v3 v3.15.0
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.11
github.com/go-pkgz/routegroup v1.4.1
github.com/go-playground/validator/v10 v10.26.0
github.com/go-webauthn/webauthn v0.13.0
github.com/go-pkgz/routegroup v1.5.2
github.com/go-playground/validator/v10 v10.27.0
github.com/go-webauthn/webauthn v0.13.4
github.com/google/uuid v1.6.0
github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus/client_golang v1.22.0
github.com/prometheus/client_golang v1.23.0
github.com/stretchr/testify v1.10.0
github.com/swaggo/swag v1.16.4
github.com/swaggo/swag v1.16.6
github.com/vardius/message-bus v1.1.5
github.com/vishvananda/netlink v1.3.1
github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.39.0
golang.org/x/crypto v0.41.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sys v0.33.0
golang.org/x/sys v0.35.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlserver v1.6.0
gorm.io/gorm v1.30.0
gorm.io/driver/sqlserver v1.6.1
gorm.io/gorm v1.30.1
)
require (
@@ -40,28 +40,28 @@ 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.8.0 // 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.1 // indirect
github.com/go-openapi/jsonpointer v0.21.2 // 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.2 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-test/deep v1.1.1 // indirect
github.com/go-webauthn/x v0.1.21 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/go-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.4 // indirect
github.com/jackc/pgx/v5 v5.7.5 // 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,29 +73,30 @@ 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.8.0 // indirect
github.com/microsoft/go-mssqldb v1.9.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/text v0.26.0 // indirect
golang.org/x/tools v0.33.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
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
google.golang.org/protobuf v1.36.6 // indirect
modernc.org/libc v1.63.0 // indirect
modernc.org/libc v1.66.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.10.0 // indirect
modernc.org/sqlite v1.37.0 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)

246
go.sum
View File

@@ -1,38 +1,46 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0/go.mod h1:bhXu1AjYL+wutSL/kpSq6s7733q2Rb0yuot9Zgfqa/0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
github.com/Azure/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 v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY=
github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/coreos/go-oidc/v3 v3.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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -40,8 +48,8 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/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=
@@ -54,38 +62,37 @@ 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.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
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/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-pkgz/routegroup v1.4.1 h1:iw1yW3lXuurZZOv/DF9fY8Mkpvy6J9UjBiP1oDIQE/s=
github.com/go-pkgz/routegroup v1.4.1/go.mod h1:kDDPDRLRiRY1vnENrZJw1jQAzQX7fvsbsHGRQFNQfKc=
github.com/go-pkgz/routegroup v1.5.2 h1:/W/5GwsHaojeEBldiSB/fcqPLm0AE/eT36reCsMEbtY=
github.com/go-pkgz/routegroup v1.5.2/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-webauthn/webauthn v0.13.0 h1:cJIL1/1l+22UekVhipziAaSgESJxokYkowUqAIsWs0Y=
github.com/go-webauthn/webauthn v0.13.0/go.mod h1:Oy9o2o79dbLKRPZWWgRIOdtBGAhKnDIaBp2PFkICRHs=
github.com/go-webauthn/x v0.1.21 h1:nFbckQxudvHEJn2uy1VEi713MeSpApoAv9eRqsb9AdQ=
github.com/go-webauthn/x v0.1.21/go.mod h1:sEYohtg1zL4An1TXIUIQ5csdmoO+WO0R4R2pGKaHYKA=
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/go-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.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-jwt/jwt/v5 v5.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=
@@ -98,7 +105,6 @@ 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.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -111,20 +117,18 @@ 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.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
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/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
@@ -139,8 +143,11 @@ 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=
@@ -157,48 +164,56 @@ 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 v0.19.0/go.mod h1:ukJCBnnzLzpVF0qYRT+eg1e+eSwjeQ7IvenUv8QPook=
github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw=
github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo=
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.9.2 h1:nY8TmFMQOHpm2qVWo6y4I2mAmVdZqlGiMGAYt64Ibbs=
github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/prometheus/common v0.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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
@@ -219,41 +234,51 @@ 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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/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/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=
@@ -263,61 +288,71 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220224120231-95c6836cb0e7/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
@@ -325,43 +360,44 @@ google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/driver/sqlserver v1.6.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=
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
gorm.io/gorm v1.30.1/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=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.63.0 h1:wKzb61wOGCzgahQBORb1b0dZonh8Ufzl/7r4Yf1D5YA=
modernc.org/libc v1.63.0/go.mod h1:wDzH1mgz1wUIEwottFt++POjGRO9sgyQKrpXaz3x89E=
modernc.org/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/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -0,0 +1,864 @@
package wgcontroller
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"strings"
"time"
probing "github.com/prometheus-community/pro-bing"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
"golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/lowlevel"
)
// region dependencies
// WgCtrlRepo is used to control local WireGuard devices via the wgctrl-go library.
type WgCtrlRepo interface {
io.Closer
Devices() ([]*wgtypes.Device, error)
Device(name string) (*wgtypes.Device, error)
ConfigureDevice(name string, cfg wgtypes.Config) error
}
// A NetlinkClient is a type which can control a netlink device.
type NetlinkClient interface {
LinkAdd(link netlink.Link) error
LinkDel(link netlink.Link) error
LinkByName(name string) (netlink.Link, error)
LinkSetUp(link netlink.Link) error
LinkSetDown(link netlink.Link) error
LinkSetMTU(link netlink.Link, mtu int) error
AddrReplace(link netlink.Link, addr *netlink.Addr) error
AddrAdd(link netlink.Link, addr *netlink.Addr) error
AddrList(link netlink.Link) ([]netlink.Addr, error)
AddrDel(link netlink.Link, addr *netlink.Addr) error
RouteAdd(route *netlink.Route) error
RouteDel(route *netlink.Route) error
RouteReplace(route *netlink.Route) error
RouteList(link netlink.Link, family int) ([]netlink.Route, error)
RouteListFiltered(family int, filter *netlink.Route, filterMask uint64) ([]netlink.Route, error)
RuleAdd(rule *netlink.Rule) error
RuleDel(rule *netlink.Rule) error
RuleList(family int) ([]netlink.Rule, error)
}
// endregion dependencies
type LocalController struct {
cfg *config.Config
wg WgCtrlRepo
nl NetlinkClient
shellCmd string
resolvConfIfacePrefix string
}
// NewLocalController creates a new local controller instance.
// This repository is used to interact with the WireGuard kernel or userspace module.
func NewLocalController(cfg *config.Config) (*LocalController, error) {
wg, err := wgctrl.New()
if err != nil {
return nil, fmt.Errorf("failed to create wgctrl client: %w", err)
}
nl := &lowlevel.NetlinkManager{}
repo := &LocalController{
cfg: cfg,
wg: wg,
nl: nl,
shellCmd: "bash", // we only support bash at the moment
resolvConfIfacePrefix: "tun.", // WireGuard interfaces have a tun. prefix in resolvconf
}
return repo, nil
}
func (c LocalController) GetId() domain.InterfaceBackend {
return config.LocalBackendName
}
// region wireguard-related
func (c LocalController) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) {
devices, err := c.wg.Devices()
if err != nil {
return nil, fmt.Errorf("device list error: %w", err)
}
interfaces := make([]domain.PhysicalInterface, 0, len(devices))
for _, device := range devices {
interfaceModel, err := c.convertWireGuardInterface(device)
if err != nil {
return nil, fmt.Errorf("interface convert failed for %s: %w", device.Name, err)
}
interfaces = append(interfaces, interfaceModel)
}
return interfaces, nil
}
func (c LocalController) GetInterface(_ context.Context, id domain.InterfaceIdentifier) (
*domain.PhysicalInterface,
error,
) {
return c.getInterface(id)
}
func (c LocalController) convertWireGuardInterface(device *wgtypes.Device) (domain.PhysicalInterface, error) {
// read data from wgctrl interface
iface := domain.PhysicalInterface{
Identifier: domain.InterfaceIdentifier(device.Name),
KeyPair: domain.KeyPair{
PrivateKey: device.PrivateKey.String(),
PublicKey: device.PublicKey.String(),
},
ListenPort: device.ListenPort,
Addresses: nil,
Mtu: 0,
FirewallMark: uint32(device.FirewallMark),
DeviceUp: false,
ImportSource: domain.ControllerTypeLocal,
DeviceType: device.Type.String(),
BytesUpload: 0,
BytesDownload: 0,
}
// read data from netlink interface
lowLevelInterface, err := c.nl.LinkByName(device.Name)
if err != nil {
return domain.PhysicalInterface{}, fmt.Errorf("netlink error for %s: %w", device.Name, err)
}
ipAddresses, err := c.nl.AddrList(lowLevelInterface)
if err != nil {
return domain.PhysicalInterface{}, fmt.Errorf("ip read error for %s: %w", device.Name, err)
}
for _, addr := range ipAddresses {
iface.Addresses = append(iface.Addresses, domain.CidrFromNetlinkAddr(addr))
}
iface.Mtu = lowLevelInterface.Attrs().MTU
iface.DeviceUp = lowLevelInterface.Attrs().OperState == netlink.OperUnknown // wg only supports unknown
if stats := lowLevelInterface.Attrs().Statistics; stats != nil {
iface.BytesUpload = stats.TxBytes
iface.BytesDownload = stats.RxBytes
}
return iface, nil
}
func (c LocalController) GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) (
[]domain.PhysicalPeer,
error,
) {
device, err := c.wg.Device(string(deviceId))
if err != nil {
return nil, fmt.Errorf("device error: %w", err)
}
peers := make([]domain.PhysicalPeer, 0, len(device.Peers))
for _, peer := range device.Peers {
peerModel, err := c.convertWireGuardPeer(&peer)
if err != nil {
return nil, fmt.Errorf("peer convert failed for %v: %w", peer.PublicKey, err)
}
peers = append(peers, peerModel)
}
return peers, nil
}
func (c LocalController) convertWireGuardPeer(peer *wgtypes.Peer) (domain.PhysicalPeer, error) {
peerModel := domain.PhysicalPeer{
Identifier: domain.PeerIdentifier(peer.PublicKey.String()),
Endpoint: "",
AllowedIPs: nil,
KeyPair: domain.KeyPair{
PublicKey: peer.PublicKey.String(),
},
PresharedKey: "",
PersistentKeepalive: int(peer.PersistentKeepaliveInterval.Seconds()),
LastHandshake: peer.LastHandshakeTime,
ProtocolVersion: peer.ProtocolVersion,
BytesUpload: uint64(peer.ReceiveBytes),
BytesDownload: uint64(peer.TransmitBytes),
ImportSource: domain.ControllerTypeLocal,
}
// Set local extras - local peers are never disabled in the kernel
peerModel.SetExtras(domain.LocalPeerExtras{
Disabled: false,
})
for _, addr := range peer.AllowedIPs {
peerModel.AllowedIPs = append(peerModel.AllowedIPs, domain.CidrFromIpNet(addr))
}
if peer.Endpoint != nil {
peerModel.Endpoint = peer.Endpoint.String()
}
if peer.PresharedKey != (wgtypes.Key{}) {
peerModel.PresharedKey = domain.PreSharedKey(peer.PresharedKey.String())
}
return peerModel, nil
}
func (c LocalController) SaveInterface(
_ context.Context,
id domain.InterfaceIdentifier,
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
) error {
physicalInterface, err := c.getOrCreateInterface(id)
if err != nil {
return err
}
if updateFunc != nil {
physicalInterface, err = updateFunc(physicalInterface)
if err != nil {
return err
}
}
if err := c.updateLowLevelInterface(physicalInterface); err != nil {
return err
}
if err := c.updateWireGuardInterface(physicalInterface); err != nil {
return err
}
return nil
}
func (c LocalController) getOrCreateInterface(id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) {
device, err := c.getInterface(id)
if err == nil {
return device, nil // interface exists
}
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("device error: %w", err) // unknown error
}
// create new device
if err := c.createLowLevelInterface(id); err != nil {
return nil, err
}
device, err = c.getInterface(id)
return device, err
}
func (c LocalController) getInterface(id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) {
device, err := c.wg.Device(string(id))
if err != nil {
return nil, err
}
pi, err := c.convertWireGuardInterface(device)
return &pi, err
}
func (c LocalController) createLowLevelInterface(id domain.InterfaceIdentifier) error {
link := &netlink.GenericLink{
LinkAttrs: netlink.LinkAttrs{
Name: string(id),
},
LinkType: "wireguard",
}
err := c.nl.LinkAdd(link)
if err != nil {
return fmt.Errorf("link add failed: %w", err)
}
return nil
}
func (c LocalController) updateLowLevelInterface(pi *domain.PhysicalInterface) error {
link, err := c.nl.LinkByName(string(pi.Identifier))
if err != nil {
return err
}
if pi.Mtu != 0 {
if err := c.nl.LinkSetMTU(link, pi.Mtu); err != nil {
return fmt.Errorf("mtu error: %w", err)
}
}
for _, addr := range pi.Addresses {
err := c.nl.AddrReplace(link, addr.NetlinkAddr())
if err != nil {
return fmt.Errorf("failed to set ip %s: %w", addr.String(), err)
}
}
// Remove unwanted IP addresses
rawAddresses, err := c.nl.AddrList(link)
if err != nil {
return fmt.Errorf("failed to fetch interface ips: %w", err)
}
for _, rawAddr := range rawAddresses {
netlinkAddr := domain.CidrFromNetlinkAddr(rawAddr)
remove := true
for _, addr := range pi.Addresses {
if addr == netlinkAddr {
remove = false
break
}
}
if !remove {
continue
}
err := c.nl.AddrDel(link, &rawAddr)
if err != nil {
return fmt.Errorf("failed to remove deprecated ip %s: %w", netlinkAddr.String(), err)
}
}
// Update link state
if pi.DeviceUp {
if err := c.nl.LinkSetUp(link); err != nil {
return fmt.Errorf("failed to bring up device: %w", err)
}
} else {
if err := c.nl.LinkSetDown(link); err != nil {
return fmt.Errorf("failed to bring down device: %w", err)
}
}
return nil
}
func (c LocalController) updateWireGuardInterface(pi *domain.PhysicalInterface) error {
pKey, err := wgtypes.NewKey(pi.KeyPair.GetPrivateKeyBytes())
if err != nil {
return err
}
var fwMark *int
if pi.FirewallMark != 0 {
intFwMark := int(pi.FirewallMark)
fwMark = &intFwMark
}
err = c.wg.ConfigureDevice(string(pi.Identifier), wgtypes.Config{
PrivateKey: &pKey,
ListenPort: &pi.ListenPort,
FirewallMark: fwMark,
ReplacePeers: false,
})
if err != nil {
return err
}
return nil
}
func (c LocalController) DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error {
if err := c.deleteLowLevelInterface(id); err != nil {
return err
}
return nil
}
func (c LocalController) deleteLowLevelInterface(id domain.InterfaceIdentifier) error {
link, err := c.nl.LinkByName(string(id))
if err != nil {
var linkNotFoundError netlink.LinkNotFoundError
if errors.As(err, &linkNotFoundError) {
return nil // ignore not found error
}
return fmt.Errorf("unable to find low level interface: %w", err)
}
err = c.nl.LinkDel(link)
if err != nil {
return fmt.Errorf("failed to delete low level interface: %w", err)
}
return nil
}
func (c LocalController) SavePeer(
_ context.Context,
deviceId domain.InterfaceIdentifier,
id domain.PeerIdentifier,
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
) error {
physicalPeer, err := c.getOrCreatePeer(deviceId, id)
if err != nil {
return err
}
physicalPeer, err = updateFunc(physicalPeer)
if err != nil {
return err
}
// Check if the peer is disabled by looking at the backend extras
// For local controller, disabled peers should be deleted
if physicalPeer.GetExtras() != nil {
switch extras := physicalPeer.GetExtras().(type) {
case domain.LocalPeerExtras:
if extras.Disabled {
// Delete the peer instead of updating it
return c.deletePeer(deviceId, id)
}
}
}
if err := c.updatePeer(deviceId, physicalPeer); err != nil {
return err
}
return nil
}
func (c LocalController) getOrCreatePeer(deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) (
*domain.PhysicalPeer,
error,
) {
peer, err := c.getPeer(deviceId, id)
if err == nil {
return peer, nil // peer exists
}
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("peer error: %w", err) // unknown error
}
// create new peer
err = c.wg.ConfigureDevice(string(deviceId), wgtypes.Config{
Peers: []wgtypes.PeerConfig{
{
PublicKey: id.ToPublicKey(),
},
},
})
if err != nil {
return nil, fmt.Errorf("peer create error for %s: %w", id.ToPublicKey(), err)
}
peer, err = c.getPeer(deviceId, id)
if err != nil {
return nil, fmt.Errorf("peer error after create: %w", err)
}
return peer, nil
}
func (c LocalController) getPeer(deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) (
*domain.PhysicalPeer,
error,
) {
if !id.IsPublicKey() {
return nil, errors.New("invalid public key")
}
device, err := c.wg.Device(string(deviceId))
if err != nil {
return nil, err
}
publicKey := id.ToPublicKey()
for _, peer := range device.Peers {
if peer.PublicKey != publicKey {
continue
}
peerModel, err := c.convertWireGuardPeer(&peer)
return &peerModel, err
}
return nil, os.ErrNotExist
}
func (c LocalController) updatePeer(deviceId domain.InterfaceIdentifier, pp *domain.PhysicalPeer) error {
cfg := wgtypes.PeerConfig{
PublicKey: pp.GetPublicKey(),
Remove: false,
UpdateOnly: true,
PresharedKey: pp.GetPresharedKey(),
Endpoint: pp.GetEndpointAddress(),
PersistentKeepaliveInterval: pp.GetPersistentKeepaliveTime(),
ReplaceAllowedIPs: true,
AllowedIPs: pp.GetAllowedIPs(),
}
err := c.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}})
if err != nil {
return err
}
return nil
}
func (c LocalController) DeletePeer(
_ context.Context,
deviceId domain.InterfaceIdentifier,
id domain.PeerIdentifier,
) error {
if !id.IsPublicKey() {
return errors.New("invalid public key")
}
err := c.deletePeer(deviceId, id)
if err != nil {
return err
}
return nil
}
func (c LocalController) deletePeer(deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error {
cfg := wgtypes.PeerConfig{
PublicKey: id.ToPublicKey(),
Remove: true,
}
err := c.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}})
if err != nil {
return err
}
return nil
}
// endregion wireguard-related
// region wg-quick-related
func (c LocalController) ExecuteInterfaceHook(id domain.InterfaceIdentifier, hookCmd string) error {
if hookCmd == "" {
return nil
}
slog.Debug("executing interface hook", "interface", id, "hook", hookCmd)
err := c.exec(hookCmd, id)
if err != nil {
return fmt.Errorf("failed to exec hook: %w", err)
}
return nil
}
func (c LocalController) SetDNS(id domain.InterfaceIdentifier, dnsStr, dnsSearchStr string) error {
if dnsStr == "" && dnsSearchStr == "" {
return nil
}
dnsServers := internal.SliceString(dnsStr)
dnsSearchDomains := internal.SliceString(dnsSearchStr)
dnsCommand := "resolvconf -a %resPref%i -m 0 -x"
dnsCommandInput := make([]string, 0, len(dnsServers)+len(dnsSearchDomains))
for _, dnsServer := range dnsServers {
dnsCommandInput = append(dnsCommandInput, fmt.Sprintf("nameserver %s", dnsServer))
}
for _, searchDomain := range dnsSearchDomains {
dnsCommandInput = append(dnsCommandInput, fmt.Sprintf("search %s", searchDomain))
}
err := c.exec(dnsCommand, id, dnsCommandInput...)
if err != nil {
return fmt.Errorf(
"failed to set dns settings (is resolvconf available?, for systemd create this symlink: ln -s /usr/bin/resolvectl /usr/local/bin/resolvconf): %w",
err,
)
}
return nil
}
func (c LocalController) UnsetDNS(id domain.InterfaceIdentifier) error {
dnsCommand := "resolvconf -d %resPref%i -f"
err := c.exec(dnsCommand, id)
if err != nil {
return fmt.Errorf("failed to unset dns settings: %w", err)
}
return nil
}
func (c LocalController) replaceCommandPlaceHolders(command string, interfaceId domain.InterfaceIdentifier) string {
command = strings.ReplaceAll(command, "%resPref", c.resolvConfIfacePrefix)
return strings.ReplaceAll(command, "%i", string(interfaceId))
}
func (c LocalController) exec(command string, interfaceId domain.InterfaceIdentifier, stdin ...string) error {
commandWithInterfaceName := c.replaceCommandPlaceHolders(command, interfaceId)
cmd := exec.Command(c.shellCmd, "-ce", commandWithInterfaceName)
if len(stdin) > 0 {
b := &bytes.Buffer{}
for _, ln := range stdin {
if _, err := fmt.Fprint(b, ln); err != nil {
return err
}
}
cmd.Stdin = b
}
out, err := cmd.CombinedOutput() // execute and wait for output
if err != nil {
return fmt.Errorf("failed to exexute shell command %s: %w", commandWithInterfaceName, err)
}
slog.Debug("executed shell command",
"command", commandWithInterfaceName,
"output", string(out))
return nil
}
// endregion wg-quick-related
// region routing-related
func (c LocalController) SyncRouteRules(_ context.Context, rules []domain.RouteRule) error {
// update fwmark rules
if err := c.setFwMarkRules(rules); err != nil {
return err
}
// update main rule
if err := c.setMainRule(rules); err != nil {
return err
}
// cleanup old main rules
if err := c.cleanupMainRule(rules); err != nil {
return err
}
return nil
}
func (c LocalController) setFwMarkRules(rules []domain.RouteRule) error {
for _, rule := range rules {
existingRules, err := c.nl.RuleList(int(rule.IpFamily))
if err != nil {
return fmt.Errorf("failed to get existing rules for family %s: %w", rule.IpFamily, err)
}
ruleExists := false
for _, existingRule := range existingRules {
if rule.FwMark == existingRule.Mark && rule.Table == existingRule.Table {
ruleExists = true
break
}
}
if ruleExists {
continue // rule already exists, no need to recreate it
}
// create a missing rule
if err := c.nl.RuleAdd(&netlink.Rule{
Family: int(rule.IpFamily),
Table: rule.Table,
Mark: rule.FwMark,
Invert: true,
SuppressIfgroup: -1,
SuppressPrefixlen: -1,
Priority: c.getRulePriority(existingRules),
Mask: nil,
Goto: -1,
Flow: -1,
}); err != nil {
return fmt.Errorf("failed to setup %s rule for fwmark %d and table %d: %w",
rule.IpFamily, rule.FwMark, rule.Table, err)
}
}
return nil
}
func (c LocalController) getRulePriority(existingRules []netlink.Rule) int {
prio := 32700 // linux main rule has a priority of 32766
for {
isFresh := true
for _, existingRule := range existingRules {
if existingRule.Priority == prio {
isFresh = false
break
}
}
if isFresh {
break
} else {
prio--
}
}
return prio
}
func (c LocalController) setMainRule(rules []domain.RouteRule) error {
var family domain.IpFamily
shouldHaveMainRule := false
for _, rule := range rules {
family = rule.IpFamily
if rule.HasDefault == true {
shouldHaveMainRule = true
break
}
}
if !shouldHaveMainRule {
return nil
}
existingRules, err := c.nl.RuleList(int(family))
if err != nil {
return fmt.Errorf("failed to get existing rules for family %s: %w", family, err)
}
ruleExists := false
for _, existingRule := range existingRules {
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
ruleExists = true
break
}
}
if ruleExists {
return nil // rule already exists, skip re-creation
}
if err := c.nl.RuleAdd(&netlink.Rule{
Family: int(family),
Table: unix.RT_TABLE_MAIN,
SuppressIfgroup: -1,
SuppressPrefixlen: 0,
Priority: c.getMainRulePriority(existingRules),
Mark: 0,
Mask: nil,
Goto: -1,
Flow: -1,
}); err != nil {
return fmt.Errorf("failed to setup rule for main table: %w", err)
}
return nil
}
func (c LocalController) getMainRulePriority(existingRules []netlink.Rule) int {
priority := c.cfg.Advanced.RulePrioOffset
for {
isFresh := true
for _, existingRule := range existingRules {
if existingRule.Priority == priority {
isFresh = false
break
}
}
if isFresh {
break
} else {
priority++
}
}
return priority
}
func (c LocalController) cleanupMainRule(rules []domain.RouteRule) error {
var family domain.IpFamily
for _, rule := range rules {
family = rule.IpFamily
break
}
existingRules, err := c.nl.RuleList(int(family))
if err != nil {
return fmt.Errorf("failed to get existing rules for family %s: %w", family, err)
}
shouldHaveMainRule := false
for _, rule := range rules {
if rule.HasDefault == true {
shouldHaveMainRule = true
break
}
}
mainRules := 0
for _, existingRule := range existingRules {
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
mainRules++
}
}
removalCount := 0
if mainRules > 1 {
removalCount = mainRules - 1 // we only want one single rule
}
if !shouldHaveMainRule {
removalCount = mainRules
}
for _, existingRule := range existingRules {
if existingRule.Table == unix.RT_TABLE_MAIN && existingRule.SuppressPrefixlen == 0 {
if removalCount > 0 {
existingRule.Family = int(family) // set family, somehow the RuleList method does not populate the family field
if err := c.nl.RuleDel(&existingRule); err != nil {
return fmt.Errorf("failed to delete main rule: %w", err)
}
removalCount--
}
}
}
return nil
}
func (c LocalController) DeleteRouteRules(_ context.Context, rules []domain.RouteRule) error {
// TODO implement me
panic("implement me")
}
// endregion routing-related
// region statistics-related
func (c LocalController) PingAddresses(
ctx context.Context,
addr string,
) (*domain.PingerResult, error) {
pinger, err := probing.NewPinger(addr)
if err != nil {
return nil, fmt.Errorf("failed to instantiate pinger for %s: %w", addr, err)
}
checkCount := 1
pinger.SetPrivileged(!c.cfg.Statistics.PingUnprivileged)
pinger.Count = checkCount
pinger.Timeout = 2 * time.Second
err = pinger.RunWithContext(ctx) // Blocks until finished.
if err != nil {
return nil, fmt.Errorf("failed to ping %s: %w", addr, err)
}
stats := pinger.Statistics()
return &domain.PingerResult{
PacketsRecv: stats.PacketsRecv,
PacketsSent: stats.PacketsSent,
Rtts: stats.Rtts,
}, nil
}
// endregion statistics-related

View File

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

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"github.com/vishvananda/netlink"
@@ -16,8 +17,9 @@ import (
// WgRepo implements all low-level WireGuard interactions.
type WgRepo struct {
wg lowlevel.WireGuardClient
nl lowlevel.NetlinkClient
wg lowlevel.WireGuardClient
nl lowlevel.NetlinkClient
log *slog.Logger
}
// NewWireGuardRepository creates a new WgRepo instance.
@@ -31,8 +33,9 @@ func NewWireGuardRepository() *WgRepo {
nl := &lowlevel.NetlinkManager{}
repo := &WgRepo{
wg: wg,
nl: nl,
wg: wg,
nl: nl,
log: slog.Default().With(slog.String("adapter", "wireguard")),
}
return repo
@@ -40,8 +43,10 @@ func NewWireGuardRepository() *WgRepo {
// GetInterfaces returns all existing WireGuard interfaces.
func (r *WgRepo) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, error) {
r.log.Debug("getting all interfaces")
devices, err := r.wg.Devices()
if err != nil {
r.log.Error("failed to get devices", "error", err)
return nil, fmt.Errorf("device list error: %w", err)
}
@@ -60,14 +65,17 @@ func (r *WgRepo) GetInterfaces(_ context.Context) ([]domain.PhysicalInterface, e
// GetInterface returns the interface with the given id.
// If no interface is found, an error os.ErrNotExist is returned.
func (r *WgRepo) GetInterface(_ context.Context, id domain.InterfaceIdentifier) (*domain.PhysicalInterface, error) {
r.log.Debug("getting interface", "id", id)
return r.getInterface(id)
}
// GetPeers returns all peers associated with the given interface id.
// If the requested interface is found, an error os.ErrNotExist is returned.
func (r *WgRepo) GetPeers(_ context.Context, deviceId domain.InterfaceIdentifier) ([]domain.PhysicalPeer, error) {
r.log.Debug("getting peers for interface", "deviceId", deviceId)
device, err := r.wg.Device(string(deviceId))
if err != nil {
r.log.Error("failed to get device", "deviceId", deviceId, "error", err)
return nil, fmt.Errorf("device error: %w", err)
}
@@ -90,6 +98,7 @@ func (r *WgRepo) GetPeer(
deviceId domain.InterfaceIdentifier,
id domain.PeerIdentifier,
) (*domain.PhysicalPeer, error) {
r.log.Debug("getting peer", "deviceId", deviceId, "peerId", id)
return r.getPeer(deviceId, id)
}
@@ -174,25 +183,31 @@ func (r *WgRepo) SaveInterface(
id domain.InterfaceIdentifier,
updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error),
) error {
r.log.Debug("saving interface", "id", id)
physicalInterface, err := r.getOrCreateInterface(id)
if err != nil {
r.log.Error("failed to get or create interface", "id", id, "error", err)
return err
}
if updateFunc != nil {
physicalInterface, err = updateFunc(physicalInterface)
if err != nil {
r.log.Error("interface update function failed", "id", id, "error", err)
return err
}
}
if err := r.updateLowLevelInterface(physicalInterface); err != nil {
r.log.Error("failed to update low level interface", "id", id, "error", err)
return err
}
if err := r.updateWireGuardInterface(physicalInterface); err != nil {
r.log.Error("failed to update wireguard interface", "id", id, "error", err)
return err
}
r.log.Debug("successfully saved interface", "id", id)
return nil
}
@@ -323,10 +338,13 @@ func (r *WgRepo) updateWireGuardInterface(pi *domain.PhysicalInterface) error {
// DeleteInterface deletes the interface with the given id.
// If the requested interface is found, no error is returned.
func (r *WgRepo) DeleteInterface(_ context.Context, id domain.InterfaceIdentifier) error {
r.log.Debug("deleting interface", "id", id)
if err := r.deleteLowLevelInterface(id); err != nil {
r.log.Error("failed to delete low level interface", "id", id, "error", err)
return err
}
r.log.Debug("successfully deleted interface", "id", id)
return nil
}
@@ -356,20 +374,25 @@ func (r *WgRepo) SavePeer(
id domain.PeerIdentifier,
updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error),
) error {
r.log.Debug("saving peer", "deviceId", deviceId, "peerId", id)
physicalPeer, err := r.getOrCreatePeer(deviceId, id)
if err != nil {
r.log.Error("failed to get or create peer", "deviceId", deviceId, "peerId", id, "error", err)
return err
}
physicalPeer, err = updateFunc(physicalPeer)
if err != nil {
r.log.Error("peer update function failed", "deviceId", deviceId, "peerId", id, "error", err)
return err
}
if err := r.updatePeer(deviceId, physicalPeer); err != nil {
r.log.Error("failed to update peer", "deviceId", deviceId, "peerId", id, "error", err)
return err
}
r.log.Debug("successfully saved peer", "deviceId", deviceId, "peerId", id)
return nil
}
@@ -441,6 +464,7 @@ func (r *WgRepo) updatePeer(deviceId domain.InterfaceIdentifier, pp *domain.Phys
err := r.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}})
if err != nil {
r.log.Error("failed to configure device for peer update", "deviceId", deviceId, "peerId", pp.Identifier, "error", err)
return err
}
@@ -450,15 +474,20 @@ func (r *WgRepo) updatePeer(deviceId domain.InterfaceIdentifier, pp *domain.Phys
// DeletePeer deletes the peer with the given id.
// If the requested interface or peer is found, no error is returned.
func (r *WgRepo) DeletePeer(_ context.Context, deviceId domain.InterfaceIdentifier, id domain.PeerIdentifier) error {
r.log.Debug("deleting peer", "deviceId", deviceId, "peerId", id)
if !id.IsPublicKey() {
return errors.New("invalid public key")
err := errors.New("invalid public key")
r.log.Error("invalid peer id", "peerId", id, "error", err)
return err
}
err := r.deletePeer(deviceId, id)
if err != nil {
r.log.Error("failed to delete peer", "deviceId", deviceId, "peerId", id, "error", err)
return err
}
r.log.Debug("successfully deleted peer", "deviceId", deviceId, "peerId", id)
return nil
}
@@ -470,6 +499,7 @@ func (r *WgRepo) deletePeer(deviceId domain.InterfaceIdentifier, id domain.PeerI
err := r.wg.ConfigureDevice(string(deviceId), wgtypes.Config{ReplacePeers: false, Peers: []wgtypes.PeerConfig{cfg}})
if err != nil {
r.log.Error("failed to configure device for peer deletion", "deviceId", deviceId, "peerId", id, "error", err)
return err
}

View File

@@ -819,6 +819,12 @@
"schema": {
"$ref": "#/definitions/model.PeerMailRequest"
}
},
{
"type": "string",
"description": "The configuration style",
"name": "style",
"in": "query"
}
],
"responses": {
@@ -858,6 +864,12 @@
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "The configuration style",
"name": "style",
"in": "query"
}
],
"responses": {
@@ -899,6 +911,12 @@
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "The configuration style",
"name": "style",
"in": "query"
}
],
"responses": {
@@ -1951,7 +1969,7 @@
"type": "string"
}
},
"Suffix": {
"Prefix": {
"type": "string"
}
}

View File

@@ -206,7 +206,7 @@ definitions:
items:
type: string
type: array
Suffix:
Prefix:
type: string
type: object
model.Peer:
@@ -1072,6 +1072,10 @@ paths:
required: true
schema:
$ref: '#/definitions/model.PeerMailRequest'
- description: The configuration style
in: query
name: style
type: string
produces:
- application/json
responses:
@@ -1097,6 +1101,10 @@ paths:
name: id
required: true
type: string
- description: The configuration style
in: query
name: style
type: string
produces:
- image/png
- application/json
@@ -1125,6 +1133,10 @@ paths:
name: id
required: true
type: string
- description: The configuration style
in: query
name: style
type: string
produces:
- application/json
responses:

View File

@@ -100,6 +100,7 @@ func (s *Server) Run(ctx context.Context, listenAddress string) {
srvContext, cancelFn := context.WithCancel(ctx)
go func() {
var err error
slog.Debug("starting server", "certFile", s.cfg.Web.CertFile, "keyFile", s.cfg.Web.KeyFile)
if s.cfg.Web.CertFile != "" && s.cfg.Web.KeyFile != "" {
err = srv.ListenAndServeTLS(s.cfg.Web.CertFile, s.cfg.Web.KeyFile)
} else {
@@ -138,7 +139,8 @@ func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
s.versions[version].HandleFunc("GET /swagger/index.html", s.rapiDocHandler(version)) // Deprecated: old link
s.versions[version].HandleFunc("GET /doc.html", s.rapiDocHandler(version))
groupSetupFn(s.versions[version])
versionGroup := s.versions[version].Group()
groupSetupFn(versionGroup)
}
}
}

View File

@@ -27,12 +27,12 @@ type PeerServicePeerManager interface {
}
type PeerServiceConfigFileManager interface {
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
}
type PeerServiceMailManager interface {
SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error
SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error
}
// endregion dependencies
@@ -95,16 +95,24 @@ func (p PeerService) DeletePeer(ctx context.Context, id domain.PeerIdentifier) e
return p.peers.DeletePeer(ctx, id)
}
func (p PeerService) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) {
return p.configFile.GetPeerConfig(ctx, id)
func (p PeerService) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) {
return p.configFile.GetPeerConfig(ctx, id, style)
}
func (p PeerService) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) {
return p.configFile.GetPeerConfigQrCode(ctx, id)
func (p PeerService) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (
io.Reader,
error,
) {
return p.configFile.GetPeerConfigQrCode(ctx, id, style)
}
func (p PeerService) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error {
return p.mailer.SendPeerEmail(ctx, linkOnly, peers...)
func (p PeerService) SendPeerEmail(
ctx context.Context,
linkOnly bool,
style string,
peers ...domain.PeerIdentifier,
) error {
return p.mailer.SendPeerEmail(ctx, linkOnly, style, peers...)
}
func (p PeerService) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) {

View File

@@ -21,17 +21,23 @@ import (
//go:embed frontend_config.js.gotpl
var frontendJs embed.FS
type ControllerManager interface {
GetControllerNames() []config.BackendBase
}
type ConfigEndpoint struct {
cfg *config.Config
authenticator Authenticator
controllerMgr ControllerManager
tpl *respond.TemplateRenderer
}
func NewConfigEndpoint(cfg *config.Config, authenticator Authenticator) ConfigEndpoint {
func NewConfigEndpoint(cfg *config.Config, authenticator Authenticator, ctrlMgr ControllerManager) ConfigEndpoint {
ep := ConfigEndpoint{
cfg: cfg,
authenticator: authenticator,
controllerMgr: ctrlMgr,
tpl: respond.NewTemplateRenderer(template.Must(template.ParseFS(frontendJs,
"frontend_config.js.gotpl"))),
}
@@ -96,13 +102,36 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sessionUser := domain.GetUserInfo(r.Context())
controllerFn := func() []model.SettingsBackendNames {
controllers := e.controllerMgr.GetControllerNames()
names := make([]model.SettingsBackendNames, 0, len(controllers))
for _, controller := range controllers {
displayName := controller.GetDisplayName()
if displayName == "" {
displayName = controller.Id // fallback to ID if no display name is set
}
if controller.Id == config.LocalBackendName {
displayName = "modals.interface-edit.backend.local" // use a localized string for the local backend
}
names = append(names, model.SettingsBackendNames{
Id: controller.Id,
Name: displayName,
})
}
return names
}
hasSocialLogin := len(e.cfg.Auth.OAuth) > 0 || len(e.cfg.Auth.OpenIDConnect) > 0 || e.cfg.Auth.WebAuthn.Enabled
// For anonymous users, we return the settings object with minimal information
if sessionUser.Id == domain.CtxUnknownUserId || sessionUser.Id == "" {
respond.JSON(w, http.StatusOK, model.Settings{
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
AvailableBackends: []model.SettingsBackendNames{}, // return an empty list instead of null
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
})
} else {
respond.JSON(w, http.StatusOK, model.Settings{
@@ -112,6 +141,7 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly,
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
MinPasswordLength: e.cfg.Auth.MinPasswordLength,
AvailableBackends: controllerFn(),
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
})
}

View File

@@ -34,11 +34,11 @@ type PeerService interface {
// DeletePeer deletes the peer with the given id.
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
// GetPeerConfig returns the peer configuration for the given id.
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
// GetPeerConfigQrCode returns the peer configuration as qr code for the given id.
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
// SendPeerEmail sends the peer configuration via email.
SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error
SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error
// GetPeerStats returns the peer stats for the given interface.
GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error)
}
@@ -355,6 +355,7 @@ func (e PeerEndpoint) handleDelete() http.HandlerFunc {
// @Summary Get peer configuration as string.
// @Produce json
// @Param id path string true "The peer identifier"
// @Param style query string false "The configuration style"
// @Success 200 {object} string
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
@@ -369,7 +370,9 @@ func (e PeerEndpoint) handleConfigGet() http.HandlerFunc {
return
}
configTxt, err := e.peerService.GetPeerConfig(r.Context(), domain.PeerIdentifier(id))
configStyle := e.getConfigStyle(r)
configTxt, err := e.peerService.GetPeerConfig(r.Context(), domain.PeerIdentifier(id), configStyle)
if err != nil {
respond.JSON(w, http.StatusInternalServerError, model.Error{
Code: http.StatusInternalServerError, Message: err.Error(),
@@ -397,6 +400,7 @@ func (e PeerEndpoint) handleConfigGet() http.HandlerFunc {
// @Produce png
// @Produce json
// @Param id path string true "The peer identifier"
// @Param style query string false "The configuration style"
// @Success 200 {file} binary
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
@@ -411,7 +415,9 @@ func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc {
return
}
configQr, err := e.peerService.GetPeerConfigQrCode(r.Context(), domain.PeerIdentifier(id))
configStyle := e.getConfigStyle(r)
configQr, err := e.peerService.GetPeerConfigQrCode(r.Context(), domain.PeerIdentifier(id), configStyle)
if err != nil {
respond.JSON(w, http.StatusInternalServerError, model.Error{
Code: http.StatusInternalServerError, Message: err.Error(),
@@ -438,6 +444,7 @@ func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc {
// @Summary Send peer configuration via email.
// @Produce json
// @Param request body model.PeerMailRequest true "The peer mail request data"
// @Param style query string false "The configuration style"
// @Success 204 "No content if mail sending was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
@@ -460,11 +467,13 @@ func (e PeerEndpoint) handleEmailPost() http.HandlerFunc {
return
}
configStyle := e.getConfigStyle(r)
peerIds := make([]domain.PeerIdentifier, len(req.Identifiers))
for i := range req.Identifiers {
peerIds[i] = domain.PeerIdentifier(req.Identifiers[i])
}
if err := e.peerService.SendPeerEmail(r.Context(), req.LinkOnly, peerIds...); err != nil {
if err := e.peerService.SendPeerEmail(r.Context(), req.LinkOnly, configStyle, peerIds...); err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
@@ -504,3 +513,11 @@ func (e PeerEndpoint) handleStatsGet() http.HandlerFunc {
respond.JSON(w, http.StatusOK, model.NewPeerStats(e.cfg.Statistics.CollectPeerData, stats))
}
}
func (e PeerEndpoint) getConfigStyle(r *http.Request) string {
configStyle := request.QueryDefault(r, "style", domain.ConfigStyleWgQuick)
if configStyle != domain.ConfigStyleWgQuick && configStyle != domain.ConfigStyleRaw {
configStyle = domain.ConfigStyleWgQuick // default to wg-quick style
}
return configStyle
}

View File

@@ -6,11 +6,17 @@ type Error struct {
}
type Settings struct {
MailLinkOnly bool `json:"MailLinkOnly"`
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
SelfProvisioning bool `json:"SelfProvisioning"`
ApiAdminOnly bool `json:"ApiAdminOnly"`
WebAuthnEnabled bool `json:"WebAuthnEnabled"`
MinPasswordLength int `json:"MinPasswordLength"`
LoginFormVisible bool `json:"LoginFormVisible"`
MailLinkOnly bool `json:"MailLinkOnly"`
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
SelfProvisioning bool `json:"SelfProvisioning"`
ApiAdminOnly bool `json:"ApiAdminOnly"`
WebAuthnEnabled bool `json:"WebAuthnEnabled"`
MinPasswordLength int `json:"MinPasswordLength"`
AvailableBackends []SettingsBackendNames `json:"AvailableBackends"`
LoginFormVisible bool `json:"LoginFormVisible"`
}
type SettingsBackendNames struct {
Id string `json:"Id"`
Name string `json:"Name"`
}

View File

@@ -4,6 +4,7 @@ import (
"time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
@@ -11,6 +12,7 @@ type Interface struct {
Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0
DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface
Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any'
Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ...
PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface
PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
@@ -57,6 +59,7 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
Identifier: string(src.Identifier),
DisplayName: src.DisplayName,
Mode: string(src.Type),
Backend: string(src.Backend),
PrivateKey: src.PrivateKey,
PublicKey: src.PublicKey,
Disabled: src.IsDisabled(),
@@ -92,6 +95,10 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
Filename: src.GetConfigFileName(),
}
if iface.Backend == "" {
iface.Backend = config.LocalBackendName // default to local backend
}
if len(peers) > 0 {
iface.TotalPeers = len(peers)
@@ -146,6 +153,7 @@ func NewDomainInterface(src *Interface) *domain.Interface {
SaveConfig: src.SaveConfig,
DisplayName: src.DisplayName,
Type: domain.InterfaceType(src.Mode),
Backend: domain.InterfaceBackend(src.Backend),
DriverType: "", // currently unused
Disabled: nil, // set below
DisabledReason: src.DisabledReason,

View File

@@ -172,13 +172,13 @@ func NewDomainPeer(src *Peer) *domain.Peer {
type MultiPeerRequest struct {
Identifiers []string `json:"Identifiers"`
Suffix string `json:"Suffix"`
Prefix string `json:"Prefix"`
}
func NewDomainPeerCreationRequest(src *MultiPeerRequest) *domain.PeerCreationRequest {
return &domain.PeerCreationRequest{
UserIdentifiers: src.Identifiers,
Suffix: src.Suffix,
Prefix: src.Prefix,
}
}

View File

@@ -23,8 +23,8 @@ type ProvisioningServicePeerManagerRepo interface {
}
type ProvisioningServiceConfigFileManagerRepo interface {
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
}
type ProvisioningService struct {
@@ -96,7 +96,7 @@ func (p ProvisioningService) GetPeerConfig(ctx context.Context, peerId domain.Pe
return nil, err
}
peerCfgReader, err := p.configFiles.GetPeerConfig(ctx, peer.Identifier)
peerCfgReader, err := p.configFiles.GetPeerConfig(ctx, peer.Identifier, domain.ConfigStyleWgQuick)
if err != nil {
return nil, err
}
@@ -119,7 +119,7 @@ func (p ProvisioningService) GetPeerQrPng(ctx context.Context, peerId domain.Pee
return nil, err
}
peerCfgQrReader, err := p.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier)
peerCfgQrReader, err := p.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier, domain.ConfigStyleWgQuick)
if err != nil {
return nil, err
}

View File

@@ -46,7 +46,7 @@ func Initialize(
users: users,
}
startupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second)
startupContext, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Switch to admin user context

View File

@@ -93,6 +93,8 @@ type Authenticator struct {
// URL prefix for the callback endpoints, this is a combination of the external URL and the API prefix
callbackUrlPrefix string
callbackUrl *url.URL
users UserManager
}
@@ -102,82 +104,136 @@ func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserM
error,
) {
a := &Authenticator{
cfg: cfg,
bus: bus,
users: users,
callbackUrlPrefix: fmt.Sprintf("%s/api/v0", extUrl),
cfg: cfg,
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)),
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := a.setupExternalAuthProviders(ctx)
parsedExtUrl, err := url.Parse(a.callbackUrlPrefix)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to parse external URL: %w", err)
}
a.callbackUrl = parsedExtUrl
return a, nil
}
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)
}
// 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
a.oauthAuthenticators = make(map[string]AuthenticatorOauth, len(a.cfg.OpenIDConnect)+len(a.cfg.OAuth))
a.ldapAuthenticators = make(map[string]AuthenticatorLdap, len(a.cfg.Ldap))
ticker := time.NewTicker(30 * time.Second) // Ticker for delay between retries
defer ticker.Stop()
for i := range a.cfg.OpenIDConnect { // OIDC
providerCfg := &a.cfg.OpenIDConnect[i]
for {
select {
case <-ticker.C:
failedOidc, failedOauth, failedLdap := a.setupExternalAuthProviders(oidcQueue, oauthQueue, ldapQueue)
if len(failedOidc) > 0 || len(failedOauth) > 0 || len(failedLdap) > 0 {
slog.Warn("failed to setup some external auth providers, retrying in 30 seconds",
"failedOidc", len(failedOidc), "failedOauth", len(failedOauth), "failedLdap", len(failedLdap))
// Retry failed providers
oidcQueue = failedOidc
oauthQueue = failedOauth
ldapQueue = failedLdap
} else {
slog.Info("successfully setup all external auth providers")
return // Exit goroutine if all providers are set up successfully
}
case <-ctx.Done():
slog.Info("context cancelled, stopping setup of external auth providers")
return // Exit goroutine if context is cancelled
}
}
}()
}
func (a *Authenticator) setupExternalAuthProviders(
oidc []config.OpenIDConnectProvider,
oauth []config.OAuthProvider,
ldap []config.LdapProvider,
) (
[]config.OpenIDConnectProvider,
[]config.OAuthProvider,
[]config.LdapProvider,
) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var failedOidc []config.OpenIDConnectProvider
var failedOauth []config.OAuthProvider
var failedLdap []config.LdapProvider
for i := range oidc { // OIDC
providerCfg := &oidc[i]
providerId := strings.ToLower(providerCfg.ProviderName)
if _, exists := a.oauthAuthenticators[providerId]; exists {
return fmt.Errorf("auth provider with name %s is already registerd", providerId)
// this is an unrecoverable error, we cannot register the same provider twice
slog.Error("OIDC auth provider is already registered", "name", providerId)
continue // skip this provider
}
redirectUrl := *extUrl
redirectUrl := *a.callbackUrl
redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback")
provider, err := newOidcAuthenticator(ctx, redirectUrl.String(), providerCfg)
if err != nil {
return fmt.Errorf("failed to setup oidc authentication provider %s: %w", providerCfg.ProviderName, err)
failedOidc = append(failedOidc, oidc[i])
slog.Error("failed to setup oidc authentication provider", "name", providerId, "error", err)
continue
}
a.oauthAuthenticators[providerId] = provider
}
for i := range a.cfg.OAuth { // PLAIN OAUTH
providerCfg := &a.cfg.OAuth[i]
for i := range oauth { // PLAIN OAUTH
providerCfg := &oauth[i]
providerId := strings.ToLower(providerCfg.ProviderName)
if _, exists := a.oauthAuthenticators[providerId]; exists {
return fmt.Errorf("auth provider with name %s is already registerd", providerId)
// this is an unrecoverable error, we cannot register the same provider twice
slog.Error("OAUTH auth provider is already registered", "name", providerId)
continue // skip this provider
}
redirectUrl := *extUrl
redirectUrl := *a.callbackUrl
redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback")
provider, err := newPlainOauthAuthenticator(ctx, redirectUrl.String(), providerCfg)
if err != nil {
return fmt.Errorf("failed to setup oauth authentication provider %s: %w", providerId, err)
failedOauth = append(failedOauth, oauth[i])
slog.Error("failed to setup oauth authentication provider", "name", providerId, "error", err)
continue
}
a.oauthAuthenticators[providerId] = provider
}
for i := range a.cfg.Ldap { // LDAP
providerCfg := &a.cfg.Ldap[i]
for i := range ldap { // LDAP
providerCfg := &ldap[i]
providerId := strings.ToLower(providerCfg.URL)
if _, exists := a.ldapAuthenticators[providerId]; exists {
return fmt.Errorf("auth provider with name %s is already registerd", providerId)
// this is an unrecoverable error, we cannot register the same provider twice
slog.Error("LDAP auth provider is already registered", "name", providerId)
continue // skip this provider
}
provider, err := newLdapAuthenticator(ctx, providerCfg)
if err != nil {
return fmt.Errorf("failed to setup ldap authentication provider %s: %w", providerId, err)
failedLdap = append(failedLdap, ldap[i])
slog.Error("failed to setup ldap authentication provider", "name", providerId, "error", err)
continue
}
a.ldapAuthenticators[providerId] = provider
}
return nil
return failedOidc, failedOauth, failedLdap
}
// GetExternalLoginProviders returns a list of all available external login providers.
@@ -434,6 +490,10 @@ 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(),
@@ -450,10 +510,6 @@ 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,

View File

@@ -54,7 +54,7 @@ func (l LdapAuthenticator) PlaintextAuthentication(userId domain.UserIdentifier,
attrs := []string{"dn"}
loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", string(userId), -1)
loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", ldap.EscapeFilter(string(userId)), -1)
searchRequest := ldap.NewSearchRequest(
l.cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit
@@ -100,7 +100,7 @@ func (l LdapAuthenticator) GetUserInfo(_ context.Context, userId domain.UserIden
attrs := internal.LdapSearchAttributes(&l.cfg.FieldMap)
loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", string(userId), -1)
loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", ldap.EscapeFilter(string(userId)), -1)
searchRequest := ldap.NewSearchRequest(
l.cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit

View File

@@ -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) (io.Reader, error)
GetPeerConfig(peer *domain.Peer, style string) (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) (io.Reader, error) {
func (m Manager) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) {
peer, err := m.wg.GetPeer(ctx, id)
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) (i
return nil, err
}
return m.tplHandler.GetPeerConfig(peer)
return m.tplHandler.GetPeerConfig(peer, style)
}
// GetPeerConfigQrCode returns a QR code image containing the configuration for the given peer.
func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) {
func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) {
peer, err := m.wg.GetPeer(ctx, id)
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)
cfgData, err := m.tplHandler.GetPeerConfig(peer, style)
if err != nil {
return nil, fmt.Errorf("failed to get peer config for %s: %w", id, err)
}

View File

@@ -55,11 +55,12 @@ 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) (io.Reader, error) {
func (c TemplateHandler) GetPeerConfig(peer *domain.Peer, style string) (io.Reader, error) {
var tplBuff bytes.Buffer
err := c.templates.ExecuteTemplate(&tplBuff, "wg_peer.tpl", map[string]any{
"Peer": peer,
"Style": style,
"Peer": peer,
"Portal": map[string]any{
"Version": "unknown",
},

View File

@@ -1,6 +1,8 @@
# AUTOGENERATED FILE - DO NOT EDIT
# This file uses wg-quick format.
# This file uses {{ .Style }} format.
{{- if eq .Style "wgquick"}}
# See https://man7.org/linux/man-pages/man8/wg-quick.8.html#CONFIGURATION
{{- end}}
# Lines starting with the -WGP- tag are used by
# the WireGuard Portal configuration parser.
@@ -21,22 +23,27 @@
# 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.FirewallMark.GetValue 0}}
FwMark = {{ .Peer.Interface.FirewallMark.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 eq .Style "wgquick"}}
# Interface hooks (optional)
{{- if .Peer.Interface.PreUp.GetValue}}
PreUp = {{ .Peer.Interface.PreUp.GetValue }}
@@ -50,6 +57,7 @@ PreDown = {{ .Peer.Interface.PreDown.GetValue }}
{{- if .Peer.Interface.PostDown.GetValue}}
PostDown = {{ .Peer.Interface.PostDown.GetValue }}
{{- end}}
{{- end}}
[Peer]
PublicKey = {{ .Peer.EndpointPublicKey.GetValue }}

View File

@@ -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) (io.Reader, error)
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
// GetPeerConfigQrCode returns the QR code for the given peer.
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
}
type UserDatabaseRepo interface {
@@ -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, peers ...domain.PeerIdentifier) error {
func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, style string, 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, peers ...doma
continue
}
err = m.sendPeerEmail(ctx, linkOnly, user, peer)
err = m.sendPeerEmail(ctx, linkOnly, style, user, peer)
if err != nil {
return fmt.Errorf("failed to send peer email for %s: %w", peerId, err)
}
@@ -132,7 +132,13 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...doma
return nil
}
func (m Manager) sendPeerEmail(ctx context.Context, linkOnly bool, user *domain.User, peer *domain.Peer) error {
func (m Manager) sendPeerEmail(
ctx context.Context,
linkOnly bool,
style string,
user *domain.User,
peer *domain.Peer,
) error {
qrName := "WireGuardQRCode.png"
configName := peer.GetConfigFileName()
@@ -148,12 +154,12 @@ func (m Manager) sendPeerEmail(ctx context.Context, linkOnly bool, user *domain.
}
} else {
peerConfig, err := m.configFiles.GetPeerConfig(ctx, peer.Identifier)
peerConfig, err := m.configFiles.GetPeerConfig(ctx, peer.Identifier, style)
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)
peerConfigQr, err := m.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier, style)
if err != nil {
return fmt.Errorf("failed to fetch peer config QR code for %s: %w", peer.Identifier, err)
}

View File

@@ -47,11 +47,18 @@ func migrateFromV1(db *gorm.DB, source, typ string) error {
}
latestVersion := "1.0.9"
if lastVersion.Version != latestVersion {
return fmt.Errorf("unsupported old version, update to database version %s first: %w", latestVersion, err)
return fmt.Errorf("unsupported old version, update to database version %s first", latestVersion)
}
slog.Info("found valid V1 database", "version", lastVersion.Version)
// validate target database
if err := validateTargetDatabase(db); err != nil {
return fmt.Errorf("target database validation failed: %w", err)
}
slog.Info("found valid target database, starting migration...")
if err := migrateV1Users(oldDb, db); err != nil {
return fmt.Errorf("user migration failed: %w", err)
}
@@ -70,6 +77,36 @@ func migrateFromV1(db *gorm.DB, source, typ string) error {
return nil
}
// validateTargetDatabase checks if the target database is empty and ready for migration.
func validateTargetDatabase(db *gorm.DB) error {
var count int64
err := db.Model(&domain.User{}).Count(&count).Error
if err != nil {
return fmt.Errorf("failed to check user table: %w", err)
}
if count > 0 {
return fmt.Errorf("target database contains %d users, please use an empty database for migration", count)
}
err = db.Model(&domain.Interface{}).Count(&count).Error
if err != nil {
return fmt.Errorf("failed to check interface table: %w", err)
}
if count > 0 {
return fmt.Errorf("target database contains %d interfaces, please use an empty database for migration", count)
}
err = db.Model(&domain.Peer{}).Count(&count).Error
if err != nil {
return fmt.Errorf("failed to check peer table: %w", err)
}
if count > 0 {
return fmt.Errorf("target database contains %d peers, please use an empty database for migration", count)
}
return nil
}
func migrateV1Users(oldDb, newDb *gorm.DB) error {
type User struct {
Email string `gorm:"primaryKey"`
@@ -123,7 +160,7 @@ func migrateV1Users(oldDb, newDb *gorm.DB) error {
LinkedPeerCount: 0,
}
if err := newDb.Save(&newUser).Error; err != nil {
if err := newDb.Create(&newUser).Error; err != nil {
return fmt.Errorf("failed to migrate user %s: %w", oldUser.Email, err)
}
@@ -217,7 +254,8 @@ func migrateV1Interfaces(oldDb, newDb *gorm.DB) error {
PeerDefPostDown: "",
}
if err := newDb.Save(&newInterface).Error; err != nil {
// Create new interface with associations
if err := newDb.Create(&newInterface).Error; err != nil {
return fmt.Errorf("failed to migrate device %s: %w", oldDevice.DeviceName, err)
}
@@ -362,7 +400,7 @@ func migrateV1Peers(oldDb, newDb *gorm.DB) error {
},
}
if err := newDb.Save(&newPeer).Error; err != nil {
if err := newDb.Create(&newPeer).Error; err != nil {
return fmt.Errorf("failed to migrate peer %s (%s): %w", oldPeer.Identifier, oldPeer.PublicKey, err)
}

View File

@@ -8,6 +8,7 @@ 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"
)
@@ -101,46 +102,46 @@ func (m Manager) sendWebhook(ctx context.Context, data io.Reader) error {
}
func (m Manager) handleUserCreateEvent(user domain.User) {
m.handleGenericEvent(WebhookEventCreate, user)
m.handleGenericEvent(WebhookEventCreate, models.NewUser(user))
}
func (m Manager) handleUserUpdateEvent(user domain.User) {
m.handleGenericEvent(WebhookEventUpdate, user)
m.handleGenericEvent(WebhookEventUpdate, models.NewUser(user))
}
func (m Manager) handleUserDeleteEvent(user domain.User) {
m.handleGenericEvent(WebhookEventDelete, user)
m.handleGenericEvent(WebhookEventDelete, models.NewUser(user))
}
func (m Manager) handlePeerCreateEvent(peer domain.Peer) {
m.handleGenericEvent(WebhookEventCreate, peer)
m.handleGenericEvent(WebhookEventCreate, models.NewPeer(peer))
}
func (m Manager) handlePeerUpdateEvent(peer domain.Peer) {
m.handleGenericEvent(WebhookEventUpdate, peer)
m.handleGenericEvent(WebhookEventUpdate, models.NewPeer(peer))
}
func (m Manager) handlePeerDeleteEvent(peer domain.Peer) {
m.handleGenericEvent(WebhookEventDelete, peer)
m.handleGenericEvent(WebhookEventDelete, models.NewPeer(peer))
}
func (m Manager) handleInterfaceCreateEvent(iface domain.Interface) {
m.handleGenericEvent(WebhookEventCreate, iface)
m.handleGenericEvent(WebhookEventCreate, models.NewInterface(iface))
}
func (m Manager) handleInterfaceUpdateEvent(iface domain.Interface) {
m.handleGenericEvent(WebhookEventUpdate, iface)
m.handleGenericEvent(WebhookEventUpdate, models.NewInterface(iface))
}
func (m Manager) handleInterfaceDeleteEvent(iface domain.Interface) {
m.handleGenericEvent(WebhookEventDelete, iface)
m.handleGenericEvent(WebhookEventDelete, models.NewInterface(iface))
}
func (m Manager) handlePeerStateChangeEvent(peerStatus domain.PeerStatus) {
func (m Manager) handlePeerStateChangeEvent(peerStatus domain.PeerStatus, peer domain.Peer) {
if peerStatus.IsConnected {
m.handleGenericEvent(WebhookEventConnect, peerStatus)
m.handleGenericEvent(WebhookEventConnect, models.NewPeerMetrics(peerStatus, peer))
} else {
m.handleGenericEvent(WebhookEventDisconnect, peerStatus)
m.handleGenericEvent(WebhookEventDisconnect, models.NewPeerMetrics(peerStatus, peer))
}
}
@@ -177,18 +178,18 @@ func (m Manager) createWebhookData(action WebhookEvent, payload any) (*WebhookDa
}
switch v := payload.(type) {
case domain.User:
case models.User:
d.Entity = WebhookEntityUser
d.Identifier = string(v.Identifier)
case domain.Peer:
d.Identifier = v.Identifier
case models.Peer:
d.Entity = WebhookEntityPeer
d.Identifier = string(v.Identifier)
case domain.Interface:
d.Identifier = v.Identifier
case models.Interface:
d.Entity = WebhookEntityInterface
d.Identifier = string(v.Identifier)
case domain.PeerStatus:
d.Entity = WebhookEntityPeer
d.Identifier = string(v.PeerId)
d.Identifier = v.Identifier
case models.PeerMetrics:
d.Entity = WebhookEntityPeerMetric
d.Identifier = v.Peer.Identifier
default:
return nil, fmt.Errorf("unsupported payload type: %T", v)
}

View File

@@ -34,9 +34,10 @@ func (d *WebhookData) Serialize() (io.Reader, error) {
type WebhookEntity = string
const (
WebhookEntityUser WebhookEntity = "user"
WebhookEntityPeer WebhookEntity = "peer"
WebhookEntityInterface WebhookEntity = "interface"
WebhookEntityUser WebhookEntity = "user"
WebhookEntityPeer WebhookEntity = "peer"
WebhookEntityPeerMetric WebhookEntity = "peer_metric"
WebhookEntityInterface WebhookEntity = "interface"
)
type WebhookEvent = string

View File

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

View File

@@ -0,0 +1,89 @@
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(),
}
}

View File

@@ -0,0 +1,50 @@
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),
}
}

View File

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

View File

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

View File

@@ -6,8 +6,6 @@ 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"
@@ -30,11 +28,6 @@ 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)
@@ -47,15 +40,20 @@ type StatisticsEventBus interface {
Publish(topic string, args ...any)
}
type pingJob struct {
Peer domain.Peer
Backend domain.InterfaceBackend
}
type StatisticsCollector struct {
cfg *config.Config
bus StatisticsEventBus
pingWaitGroup sync.WaitGroup
pingJobs chan domain.Peer
pingJobs chan pingJob
db StatisticsDatabaseRepo
wg StatisticsInterfaceController
wg *ControllerManager
ms StatisticsMetricsServer
peerChangeEvent chan domain.PeerIdentifier
@@ -66,7 +64,7 @@ func NewStatisticsCollector(
cfg *config.Config,
bus StatisticsEventBus,
db StatisticsDatabaseRepo,
wg StatisticsInterfaceController,
wg *ControllerManager,
ms StatisticsMetricsServer,
) (*StatisticsCollector, error) {
c := &StatisticsCollector{
@@ -117,7 +115,7 @@ func (c *StatisticsCollector) collectInterfaceData(ctx context.Context) {
}
for _, in := range interfaces {
physicalInterface, err := c.wg.GetInterface(ctx, in.Identifier)
physicalInterface, err := c.wg.GetController(in).GetInterface(ctx, in.Identifier)
if err != nil {
slog.Warn("failed to load physical interface for data collection", "interface", in.Identifier,
"error", err)
@@ -169,7 +167,7 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
}
for _, in := range interfaces {
peers, err := c.wg.GetPeers(ctx, in.Identifier)
peers, err := c.wg.GetController(in).GetPeers(ctx, in.Identifier)
if err != nil {
slog.Warn("failed to fetch peers for data collection", "interface", in.Identifier, "error", err)
continue
@@ -197,6 +195,7 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
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
}
@@ -213,8 +212,14 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
}
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)
c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus, *peerModel)
}
}
}
@@ -264,7 +269,7 @@ func (c *StatisticsCollector) startPingWorkers(ctx context.Context) {
c.pingWaitGroup = sync.WaitGroup{}
c.pingWaitGroup.Add(c.cfg.Statistics.PingCheckWorkers)
c.pingJobs = make(chan domain.Peer, c.cfg.Statistics.PingCheckWorkers)
c.pingJobs = make(chan pingJob, c.cfg.Statistics.PingCheckWorkers)
// start workers
for i := 0; i < c.cfg.Statistics.PingCheckWorkers; i++ {
@@ -307,7 +312,10 @@ func (c *StatisticsCollector) enqueuePingChecks(ctx context.Context) {
continue
}
for _, peer := range peers {
c.pingJobs <- peer
c.pingJobs <- pingJob{
Peer: peer,
Backend: in.Backend,
}
}
}
}
@@ -316,11 +324,14 @@ func (c *StatisticsCollector) enqueuePingChecks(ctx context.Context) {
func (c *StatisticsCollector) pingWorker(ctx context.Context) {
defer c.pingWaitGroup.Done()
for peer := range c.pingJobs {
for job := range c.pingJobs {
peer := job.Peer
backend := job.Backend
var connectionStateChanged bool
var newPeerStatus domain.PeerStatus
peerPingable := c.isPeerPingable(ctx, peer)
peerPingable := c.isPeerPingable(ctx, backend, peer)
slog.Debug("peer ping check completed", "peer", peer.Identifier, "pingable", peerPingable)
now := time.Now()
@@ -356,12 +367,16 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) {
if connectionStateChanged {
// publish event if connection state changed
c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus)
c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus, peer)
}
}
}
func (c *StatisticsCollector) isPeerPingable(ctx context.Context, peer domain.Peer) bool {
func (c *StatisticsCollector) isPeerPingable(
ctx context.Context,
backend domain.InterfaceBackend,
peer domain.Peer,
) bool {
if !c.cfg.Statistics.UsePingChecks {
return false
}
@@ -371,23 +386,13 @@ func (c *StatisticsCollector) isPeerPingable(ctx context.Context, peer domain.Pe
return false
}
pinger, err := probing.NewPinger(checkAddr)
stats, err := c.wg.GetControllerByName(backend).PingAddresses(ctx, checkAddr)
if err != nil {
slog.Debug("failed to instantiate pinger", "peer", peer.Identifier, "address", checkAddr, "error", err)
slog.Debug("failed to ping peer", "peer", peer.Identifier, "error", err)
return false
}
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
return stats.IsPingable()
}
func (c *StatisticsCollector) updateInterfaceMetrics(status domain.InterfaceStatus) {

View File

@@ -37,25 +37,6 @@ 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
@@ -75,7 +56,7 @@ type Manager struct {
cfg *config.Config
bus EventBus
db InterfaceAndPeerDatabaseRepo
wg InterfaceController
wg *ControllerManager
quick WgQuickController
userLockMap *sync.Map
@@ -84,7 +65,7 @@ type Manager struct {
func NewWireGuardManager(
cfg *config.Config,
bus EventBus,
wg InterfaceController,
wg *ControllerManager,
quick WgQuickController,
db InterfaceAndPeerDatabaseRepo,
) (*Manager, error) {

View File

@@ -11,6 +11,7 @@ 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"
)
@@ -21,12 +22,17 @@ func (m Manager) GetImportableInterfaces(ctx context.Context) ([]domain.Physical
return nil, err
}
physicalInterfaces, err := m.wg.GetInterfaces(ctx)
if err != nil {
return nil, err
var allPhysicalInterfaces []domain.PhysicalInterface
for _, wgBackend := range m.wg.GetAllControllers() {
physicalInterfaces, err := wgBackend.GetInterfaces(ctx)
if err != nil {
return nil, err
}
allPhysicalInterfaces = append(allPhysicalInterfaces, physicalInterfaces...)
}
return physicalInterfaces, nil
return allPhysicalInterfaces, nil
}
// GetInterfaceAndPeers returns the interface and all peers for the given interface identifier.
@@ -109,47 +115,49 @@ func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.Inter
return 0, err
}
physicalInterfaces, err := m.wg.GetInterfaces(ctx)
if err != nil {
return 0, err
}
// if no filter is given, exclude already existing interfaces
var excludedInterfaces []domain.InterfaceIdentifier
if len(filter) == 0 {
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return 0, err
}
for _, existingInterface := range existingInterfaces {
excludedInterfaces = append(excludedInterfaces, existingInterface.Identifier)
}
}
imported := 0
for _, physicalInterface := range physicalInterfaces {
if slices.Contains(excludedInterfaces, physicalInterface.Identifier) {
continue
}
if len(filter) != 0 && !slices.Contains(filter, physicalInterface.Identifier) {
continue
}
slog.Info("importing new interface", "interface", physicalInterface.Identifier)
physicalPeers, err := m.wg.GetPeers(ctx, physicalInterface.Identifier)
for _, wgBackend := range m.wg.GetAllControllers() {
physicalInterfaces, err := wgBackend.GetInterfaces(ctx)
if err != nil {
return 0, err
}
err = m.importInterface(ctx, &physicalInterface, physicalPeers)
if err != nil {
return 0, fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err)
// if no filter is given, exclude already existing interfaces
var excludedInterfaces []domain.InterfaceIdentifier
if len(filter) == 0 {
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return 0, err
}
for _, existingInterface := range existingInterfaces {
excludedInterfaces = append(excludedInterfaces, existingInterface.Identifier)
}
}
slog.Info("imported new interface", "interface", physicalInterface.Identifier, "peers", len(physicalPeers))
imported++
for _, physicalInterface := range physicalInterfaces {
if slices.Contains(excludedInterfaces, physicalInterface.Identifier) {
continue
}
if len(filter) != 0 && !slices.Contains(filter, physicalInterface.Identifier) {
continue
}
slog.Info("importing new interface", "interface", physicalInterface.Identifier)
physicalPeers, err := wgBackend.GetPeers(ctx, physicalInterface.Identifier)
if err != nil {
return 0, err
}
err = m.importInterface(ctx, wgBackend, &physicalInterface, physicalPeers)
if err != nil {
return 0, fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err)
}
slog.Info("imported new interface", "interface", physicalInterface.Identifier, "peers", len(physicalPeers))
imported++
}
}
return imported, nil
@@ -213,10 +221,19 @@ func (m Manager) RestoreInterfaceState(
return fmt.Errorf("failed to load peers for %s: %w", iface.Identifier, err)
}
_, err = m.wg.GetInterface(ctx, iface.Identifier)
_, err = m.wg.GetController(iface).GetInterface(ctx, iface.Identifier)
if err != nil && !iface.IsDisabled() {
slog.Debug("creating missing interface", "interface", iface.Identifier)
// temporarily disable interface in database so that the current state is reflected correctly
_ = m.db.SaveInterface(ctx, iface.Identifier,
func(in *domain.Interface) (*domain.Interface, error) {
now := time.Now()
in.Disabled = &now // set
in.DisabledReason = domain.DisabledReasonInterfaceMissing
return in, nil
})
// try to create a new interface
_, err = m.saveInterface(ctx, &iface)
if err != nil {
@@ -260,18 +277,14 @@ func (m Manager) RestoreInterfaceState(
// restore peers
for _, peer := range peers {
switch {
case iface.IsDisabled(): // if interface is disabled, delete all peers
if err := m.wg.DeletePeer(ctx, iface.Identifier, peer.Identifier); err != nil {
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 {
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.SavePeer(ctx, iface.Identifier, peer.Identifier,
err := m.wg.GetController(iface).SavePeer(ctx, iface.Identifier, peer.Identifier,
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
domain.MergeToPhysicalPeer(pp, &peer)
return pp, nil
@@ -284,7 +297,7 @@ func (m Manager) RestoreInterfaceState(
}
// remove non-wgportal peers
physicalPeers, _ := m.wg.GetPeers(ctx, iface.Identifier)
physicalPeers, _ := m.wg.GetController(iface).GetPeers(ctx, iface.Identifier)
for _, physicalPeer := range physicalPeers {
isWgPortalPeer := false
for _, peer := range peers {
@@ -294,7 +307,8 @@ func (m Manager) RestoreInterfaceState(
}
}
if !isWgPortalPeer {
err := m.wg.DeletePeer(ctx, iface.Identifier, domain.PeerIdentifier(physicalPeer.PublicKey))
err := m.wg.GetController(iface).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)
@@ -459,9 +473,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.GetInterface(ctx, id)
physicalInterface, _ := m.wg.GetController(*existingInterface).GetInterface(ctx, id)
if err := m.handleInterfacePreSaveHooks(true, existingInterface); err != nil {
if err := m.handleInterfacePreSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil {
return fmt.Errorf("pre-delete hooks failed: %w", err)
}
@@ -473,7 +487,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
return fmt.Errorf("peer deletion failure: %w", err)
}
if err := m.wg.DeleteInterface(ctx, id); err != nil {
if err := m.wg.GetController(*existingInterface).DeleteInterface(ctx, id); err != nil {
return fmt.Errorf("wireguard deletion failure: %w", err)
}
@@ -490,7 +504,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
Table: existingInterface.GetRoutingTable(),
})
if err := m.handleInterfacePostSaveHooks(true, existingInterface); err != nil {
if err := m.handleInterfacePostSaveHooks(existingInterface, !existingInterface.IsDisabled(), false); err != nil {
return fmt.Errorf("post-delete hooks failed: %w", err)
}
@@ -509,9 +523,9 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
return nil, fmt.Errorf("interface validation failed: %w", err)
}
stateChanged := m.hasInterfaceStateChanged(ctx, iface)
oldEnabled, newEnabled := m.getInterfaceStateHistory(ctx, iface)
if err := m.handleInterfacePreSaveHooks(stateChanged, iface); err != nil {
if err := m.handleInterfacePreSaveHooks(iface, oldEnabled, newEnabled); err != nil {
return nil, fmt.Errorf("pre-save hooks failed: %w", err)
}
@@ -522,7 +536,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.SaveInterface(ctx, iface.Identifier,
err := m.wg.GetController(*iface).SaveInterface(ctx, iface.Identifier,
func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
domain.MergeToPhysicalInterface(pi, iface)
return pi, nil
@@ -538,7 +552,7 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
}
if iface.IsDisabled() {
physicalInterface, _ := m.wg.GetInterface(ctx, iface.Identifier)
physicalInterface, _ := m.wg.GetController(*iface).GetInterface(ctx, iface.Identifier)
fwMark := iface.FirewallMark
if physicalInterface != nil && fwMark == 0 {
fwMark = physicalInterface.FirewallMark
@@ -551,10 +565,31 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
m.bus.Publish(app.TopicRouteUpdate, "interface updated: "+string(iface.Identifier))
}
if err := m.handleInterfacePostSaveHooks(stateChanged, iface); err != nil {
if err := m.handleInterfacePostSaveHooks(iface, oldEnabled, newEnabled); 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{
@@ -566,32 +601,13 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
return iface, nil
}
func (m Manager) hasInterfaceStateChanged(ctx context.Context, iface *domain.Interface) bool {
func (m Manager) getInterfaceStateHistory(ctx context.Context, iface *domain.Interface) (oldEnabled, newEnabled bool) {
oldInterface, err := m.db.GetInterface(ctx, iface.Identifier)
if err != nil {
return false
return false, !iface.IsDisabled() // if the interface did not exist, we assume it was not enabled
}
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
return !oldInterface.IsDisabled(), !iface.IsDisabled()
}
func (m Manager) handleInterfacePreSaveActions(iface *domain.Interface) error {
@@ -607,12 +623,14 @@ func (m Manager) handleInterfacePreSaveActions(iface *domain.Interface) error {
return nil
}
func (m Manager) handleInterfacePreSaveHooks(stateChanged bool, iface *domain.Interface) error {
if !stateChanged {
func (m Manager) handleInterfacePreSaveHooks(iface *domain.Interface, oldEnabled, newEnabled bool) error {
if oldEnabled == newEnabled {
return nil // do nothing if state did not change
}
if !iface.IsDisabled() {
slog.Debug("executing pre-save hooks", "interface", iface.Identifier, "up", newEnabled)
if newEnabled {
if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PreUp); err != nil {
return fmt.Errorf("failed to execute pre-up hook: %w", err)
}
@@ -624,12 +642,14 @@ func (m Manager) handleInterfacePreSaveHooks(stateChanged bool, iface *domain.In
return nil
}
func (m Manager) handleInterfacePostSaveHooks(stateChanged bool, iface *domain.Interface) error {
if !stateChanged {
func (m Manager) handleInterfacePostSaveHooks(iface *domain.Interface, oldEnabled, newEnabled bool) error {
if oldEnabled == newEnabled {
return nil // do nothing if state did not change
}
if !iface.IsDisabled() {
slog.Debug("executing post-save hooks", "interface", iface.Identifier, "up", newEnabled)
if newEnabled {
if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PostUp); err != nil {
return fmt.Errorf("failed to execute post-up hook: %w", err)
}
@@ -760,7 +780,12 @@ func (m Manager) getFreshListenPort(ctx context.Context) (port int, err error) {
return
}
func (m Manager) importInterface(ctx context.Context, in *domain.PhysicalInterface, peers []domain.PhysicalPeer) error {
func (m Manager) importInterface(
ctx context.Context,
backend InterfaceController,
in *domain.PhysicalInterface,
peers []domain.PhysicalPeer,
) error {
now := time.Now()
iface := domain.ConvertPhysicalInterface(in)
iface.BaseModel = domain.BaseModel{
@@ -769,8 +794,20 @@ func (m Manager) importInterface(ctx context.Context, in *domain.PhysicalInterfa
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
@@ -821,16 +858,20 @@ 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
peer.DisplayName = "Autodetected Peer (" + peer.Interface.PublicKey[0:8] + ")"
displayName = "Autodetected Peer (" + peer.Interface.PublicKey[0:8] + ")"
case domain.InterfaceTypeClient:
peer.Interface.Type = domain.InterfaceTypeServer
peer.DisplayName = "Autodetected Endpoint (" + peer.Interface.PublicKey[0:8] + ")"
displayName = "Autodetected Endpoint (" + peer.Interface.PublicKey[0:8] + ")"
case domain.InterfaceTypeServer:
peer.Interface.Type = domain.InterfaceTypeClient
peer.DisplayName = "Autodetected Client (" + peer.Interface.PublicKey[0:8] + ")"
displayName = "Autodetected Client (" + peer.Interface.PublicKey[0:8] + ")"
}
if peer.DisplayName == "" {
peer.DisplayName = displayName // use auto-generated display name if not set
}
err := m.db.SavePeer(ctx, peer.Identifier, func(_ *domain.Peer) (*domain.Peer, error) {
@@ -844,12 +885,12 @@ func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain
}
func (m Manager) deleteInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) error {
allPeers, err := m.db.GetInterfacePeers(ctx, id)
iface, allPeers, err := m.db.GetInterfaceAndPeers(ctx, id)
if err != nil {
return err
}
for _, peer := range allPeers {
err = m.wg.DeletePeer(ctx, id, peer.Identifier)
err = m.wg.GetController(*iface).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)
}

View File

@@ -188,29 +188,29 @@ 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)
}
}
// 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) {
@@ -257,7 +257,7 @@ func (m Manager) CreateMultiplePeers(
return nil, err
}
var newPeers []*domain.Peer
createdPeers := make([]domain.Peer, 0, len(r.UserIdentifiers))
for _, id := range r.UserIdentifiers {
freshPeer, err := m.PreparePeer(ctx, interfaceId)
@@ -266,27 +266,22 @@ func (m Manager) CreateMultiplePeers(
}
freshPeer.UserIdentifier = domain.UserIdentifier(id) // use id as user identifier. peers are allowed to have invalid user identifiers
if r.Suffix != "" {
freshPeer.DisplayName += " " + r.Suffix
if r.Prefix != "" {
freshPeer.DisplayName = r.Prefix + " " + freshPeer.DisplayName
}
if err := m.validatePeerCreation(ctx, nil, freshPeer); err != nil {
return nil, fmt.Errorf("creation not allowed: %w", err)
}
newPeers = append(newPeers, freshPeer)
}
// Save immediately to reserve the assigned IPs so the next prepared peer gets the next free IPs
if err := m.savePeers(ctx, freshPeer); err != nil {
return nil, fmt.Errorf("failed to create new peer %s: %w", freshPeer.Identifier, err)
}
err := m.savePeers(ctx, newPeers...)
if err != nil {
return nil, fmt.Errorf("failed to create new peers: %w", err)
}
createdPeers = append(createdPeers, *freshPeer)
createdPeers := make([]domain.Peer, len(newPeers))
for i := range newPeers {
createdPeers[i] = *newPeers[i]
m.bus.Publish(app.TopicPeerCreated, *newPeers[i])
m.bus.Publish(app.TopicPeerCreated, *freshPeer)
}
return createdPeers, nil
@@ -376,7 +371,12 @@ func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
return fmt.Errorf("delete not allowed: %w", err)
}
err = m.wg.DeletePeer(ctx, peer.InterfaceIdentifier, id)
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)
if err != nil {
return fmt.Errorf("wireguard failed to delete peer %s: %w", id, err)
}
@@ -438,35 +438,28 @@ 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 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)
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
})
if err != nil {
return nil, fmt.Errorf("failed to save wireguard peer %s: %w", peer.Identifier, err)
}
return peer, nil
})
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
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,
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
domain.MergeToPhysicalPeer(pp, peer)
return pp, nil
})
if err != nil {
return nil, fmt.Errorf("failed to save wireguard peer %s: %w", peer.Identifier, err)
}
return peer, nil
})
if err != nil {
return fmt.Errorf("save failure for peer %s: %w", peer.Identifier, err)
}

View File

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

View File

@@ -44,6 +44,8 @@ type Config struct {
LimitAdditionalUserPeers int `yaml:"limit_additional_user_peers"`
} `yaml:"advanced"`
Backend Backend `yaml:"backend"`
Statistics struct {
UsePingChecks bool `yaml:"use_ping_checks"`
PingCheckWorkers int `yaml:"ping_check_workers"`
@@ -99,6 +101,12 @@ func (c *Config) LogStartupValues() {
"minPasswordLength", c.Auth.MinPasswordLength,
"hideLoginForm", c.Auth.HideLoginForm,
)
slog.Debug("Config Backend",
"defaultBackend", c.Backend.Default,
"extraBackends", len(c.Backend.Mikrotik),
)
}
// defaultConfig returns the default configuration
@@ -122,6 +130,10 @@ func defaultConfig() *Config {
DSN: "data/sqlite.db",
}
cfg.Backend = Backend{
Default: LocalBackendName, // local backend is the default (using wgcrtl)
}
cfg.Web = WebConfig{
RequestLogging: false,
ExternalUrl: "http://localhost:8888",
@@ -201,6 +213,10 @@ func GetConfig() (*Config, error) {
}
cfg.Web.Sanitize()
err := cfg.Backend.Validate()
if err != nil {
return nil, err
}
return cfg, nil
}

View File

@@ -62,4 +62,7 @@ const (
LockedReasonAdmin = "locked by admin"
LockedReasonApi = "locked by admin"
ConfigStyleRaw = "raw"
ConfigStyleWgQuick = "wgquick"
)

View File

@@ -0,0 +1,32 @@
package domain
// ControllerType defines the type of controller used to manage interfaces.
const (
ControllerTypeMikrotik = "mikrotik"
ControllerTypeLocal = "wgctrl"
)
// Controller extras can be used to store additional information available for specific controllers only.
type MikrotikInterfaceExtras struct {
Id string // internal mikrotik ID
Comment string
Disabled bool
}
type MikrotikPeerExtras struct {
Id string // internal mikrotik ID
Name string
Comment string
IsResponder bool
Disabled bool
ClientEndpoint string
ClientAddress string
ClientDns string
ClientKeepalive int
}
type LocalPeerExtras struct {
Disabled bool
}

View File

@@ -10,6 +10,8 @@ import (
"strings"
"time"
"golang.org/x/sys/unix"
"github.com/h44z/wg-portal/internal"
)
@@ -23,6 +25,7 @@ var allowedFileNameRegex = regexp.MustCompile("[^a-zA-Z0-9-_]+")
type InterfaceIdentifier string
type InterfaceType string
type InterfaceBackend string
type Interface struct {
BaseModel
@@ -49,11 +52,12 @@ type Interface struct {
SaveConfig bool // automatically persist config changes to the wgX.conf file
// WG Portal specific
DisplayName string // a nice display name/ description for the interface
Type InterfaceType // the interface type, either InterfaceTypeServer or InterfaceTypeClient
DriverType string // the interface driver type (linux, software, ...)
Disabled *time.Time `gorm:"index"` // flag that specifies if the interface is enabled (up) or not (down)
DisabledReason string // the reason why the interface has been disabled
DisplayName string // a nice display name/ description for the interface
Type InterfaceType // the interface type, either InterfaceTypeServer or InterfaceTypeClient
Backend InterfaceBackend // the backend that is used to manage the interface (wgctrl, mikrotik, ...)
DriverType string // the interface driver type (linux, software, ...)
Disabled *time.Time `gorm:"index"` // flag that specifies if the interface is enabled (up) or not (down)
DisabledReason string // the reason why the interface has been disabled
// Default settings for the peer, used for new peers, those settings will be published to ConfigOption options of
// the peer config
@@ -204,9 +208,31 @@ type PhysicalInterface struct {
BytesUpload uint64
BytesDownload uint64
backendExtras any // additional backend-specific extras, e.g., domain.MikrotikInterfaceExtras
}
func (p *PhysicalInterface) GetExtras() any {
return p.backendExtras
}
func (p *PhysicalInterface) SetExtras(extras any) {
switch extras.(type) {
case MikrotikInterfaceExtras: // OK
default: // we only support MikrotikInterfaceExtras for now
panic(fmt.Sprintf("unsupported interface backend extras type %T", extras))
}
p.backendExtras = extras
}
func ConvertPhysicalInterface(pi *PhysicalInterface) *Interface {
networks := make([]Cidr, 0, len(pi.Addresses))
for _, addr := range pi.Addresses {
networks = append(networks, addr.NetworkAddr())
}
// create a new basic interface with the data from the physical interface
iface := &Interface{
Identifier: pi.Identifier,
KeyPair: pi.KeyPair,
@@ -226,11 +252,11 @@ func ConvertPhysicalInterface(pi *PhysicalInterface) *Interface {
Type: InterfaceTypeAny,
DriverType: pi.DeviceType,
Disabled: nil,
PeerDefNetworkStr: "",
PeerDefNetworkStr: CidrsToString(networks),
PeerDefDnsStr: "",
PeerDefDnsSearchStr: "",
PeerDefEndpoint: "",
PeerDefAllowedIPsStr: "",
PeerDefAllowedIPsStr: CidrsToString(networks),
PeerDefMtu: pi.Mtu,
PeerDefPersistentKeepalive: 0,
PeerDefFirewallMark: 0,
@@ -241,6 +267,23 @@ func ConvertPhysicalInterface(pi *PhysicalInterface) *Interface {
PeerDefPostDown: "",
}
if pi.GetExtras() == nil {
return iface
}
// enrich the data with controller-specific extras
now := time.Now()
switch pi.ImportSource {
case ControllerTypeMikrotik:
extras := pi.GetExtras().(MikrotikInterfaceExtras)
iface.DisplayName = extras.Comment
if extras.Disabled {
iface.Disabled = &now
} else {
iface.Disabled = nil
}
}
return iface
}
@@ -253,6 +296,15 @@ func MergeToPhysicalInterface(pi *PhysicalInterface, i *Interface) {
pi.FirewallMark = i.FirewallMark
pi.DeviceUp = !i.IsDisabled()
pi.Addresses = i.Addresses
switch pi.ImportSource {
case ControllerTypeMikrotik:
extras := MikrotikInterfaceExtras{
Comment: i.DisplayName,
Disabled: i.IsDisabled(),
}
pi.SetExtras(extras)
}
}
type RoutingTableInfo struct {
@@ -279,3 +331,30 @@ func (r RoutingTableInfo) GetRoutingTable() int {
return r.Table
}
type IpFamily int
const (
IpFamilyIPv4 IpFamily = unix.AF_INET
IpFamilyIPv6 IpFamily = unix.AF_INET6
)
func (f IpFamily) String() string {
switch f {
case IpFamilyIPv4:
return "IPv4"
case IpFamilyIPv6:
return "IPv6"
default:
return "unknown"
}
}
// RouteRule represents a routing table rule.
type RouteRule struct {
InterfaceId InterfaceIdentifier
IpFamily IpFamily
FwMark uint32
Table int
HasDefault bool
}

View File

@@ -129,7 +129,7 @@ func (p *Peer) GenerateDisplayName(prefix string) {
p.DisplayName = fmt.Sprintf("%sPeer %s", prefix, internal.TruncateString(string(p.Identifier), 8))
}
// OverwriteUserEditableFields overwrites the user editable fields of the peer with the values from the userPeer
// OverwriteUserEditableFields overwrites the user-editable fields of the peer with the values from the userPeer
func (p *Peer) OverwriteUserEditableFields(userPeer *Peer, cfg *config.Config) {
p.DisplayName = userPeer.DisplayName
if cfg.Core.EditableKeys {
@@ -182,9 +182,12 @@ type PhysicalPeer struct {
BytesUpload uint64 // upload bytes are the number of bytes that the remote peer has sent to the server
BytesDownload uint64 // upload bytes are the number of bytes that the remote peer has received from the server
ImportSource string // import source (wgctrl, file, ...)
backendExtras any // additional backend-specific extras, e.g., domain.MikrotikPeerExtras
}
func (p PhysicalPeer) GetPresharedKey() *wgtypes.Key {
func (p *PhysicalPeer) GetPresharedKey() *wgtypes.Key {
if p.PresharedKey == "" {
return nil
}
@@ -196,7 +199,7 @@ func (p PhysicalPeer) GetPresharedKey() *wgtypes.Key {
return &key
}
func (p PhysicalPeer) GetEndpointAddress() *net.UDPAddr {
func (p *PhysicalPeer) GetEndpointAddress() *net.UDPAddr {
if p.Endpoint == "" {
return nil
}
@@ -208,7 +211,7 @@ func (p PhysicalPeer) GetEndpointAddress() *net.UDPAddr {
return addr
}
func (p PhysicalPeer) GetPersistentKeepaliveTime() *time.Duration {
func (p *PhysicalPeer) GetPersistentKeepaliveTime() *time.Duration {
if p.PersistentKeepalive == 0 {
return nil
}
@@ -217,7 +220,7 @@ func (p PhysicalPeer) GetPersistentKeepaliveTime() *time.Duration {
return &keepAliveDuration
}
func (p PhysicalPeer) GetAllowedIPs() []net.IPNet {
func (p *PhysicalPeer) GetAllowedIPs() []net.IPNet {
allowedIPs := make([]net.IPNet, len(p.AllowedIPs))
for i, ip := range p.AllowedIPs {
allowedIPs[i] = *ip.IpNet()
@@ -226,6 +229,21 @@ func (p PhysicalPeer) GetAllowedIPs() []net.IPNet {
return allowedIPs
}
func (p *PhysicalPeer) GetExtras() any {
return p.backendExtras
}
func (p *PhysicalPeer) SetExtras(extras any) {
switch extras.(type) {
case MikrotikPeerExtras: // OK
case LocalPeerExtras: // OK
default: // we only support MikrotikPeerExtras and LocalPeerExtras for now
panic(fmt.Sprintf("unsupported peer backend extras type %T", extras))
}
p.backendExtras = extras
}
func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
peer := &Peer{
Endpoint: NewConfigOption(pp.Endpoint, true),
@@ -244,6 +262,44 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
},
}
if pp.GetExtras() == nil {
return peer
}
// enrich the data with controller-specific extras
now := time.Now()
switch pp.ImportSource {
case ControllerTypeMikrotik:
extras := pp.GetExtras().(MikrotikPeerExtras)
peer.Notes = extras.Comment
peer.DisplayName = extras.Name
if extras.ClientEndpoint != "" { // if the client endpoint is set, we assume that this is a client peer
peer.Endpoint = NewConfigOption(extras.ClientEndpoint, true)
peer.Interface.Type = InterfaceTypeClient
peer.Interface.Addresses, _ = CidrsFromString(extras.ClientAddress)
peer.Interface.DnsStr = NewConfigOption(extras.ClientDns, true)
peer.PersistentKeepalive = NewConfigOption(extras.ClientKeepalive, true)
} else {
peer.Interface.Type = InterfaceTypeServer
}
if extras.Disabled {
peer.Disabled = &now
peer.DisabledReason = "Disabled by Mikrotik controller"
} else {
peer.Disabled = nil
peer.DisabledReason = ""
}
case ControllerTypeLocal:
extras := pp.GetExtras().(LocalPeerExtras)
if extras.Disabled {
peer.Disabled = &now
peer.DisabledReason = "Disabled by Local controller"
} else {
peer.Disabled = nil
peer.DisabledReason = ""
}
}
return peer
}
@@ -265,9 +321,30 @@ func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
pp.PresharedKey = p.PresharedKey
pp.PublicKey = p.Interface.PublicKey
pp.PersistentKeepalive = p.PersistentKeepalive.GetValue()
switch pp.ImportSource {
case ControllerTypeMikrotik:
extras := MikrotikPeerExtras{
Id: "",
Name: p.DisplayName,
Comment: p.Notes,
IsResponder: false,
Disabled: p.IsDisabled(),
ClientEndpoint: p.Endpoint.GetValue(),
ClientAddress: CidrsToString(p.Interface.Addresses),
ClientDns: p.Interface.DnsStr.GetValue(),
ClientKeepalive: p.PersistentKeepalive.GetValue(),
}
pp.SetExtras(extras)
case ControllerTypeLocal:
extras := LocalPeerExtras{
Disabled: p.IsDisabled(),
}
pp.SetExtras(extras)
}
}
type PeerCreationRequest struct {
UserIdentifiers []string
Suffix string
Prefix string
}

View File

@@ -1,6 +1,8 @@
package domain
import "time"
import (
"time"
)
type PeerStatus struct {
PeerId PeerIdentifier `gorm:"primaryKey;column:identifier" json:"PeerId"`
@@ -37,3 +39,25 @@ type InterfaceStatus struct {
BytesReceived uint64 `gorm:"column:received"`
BytesTransmitted uint64 `gorm:"column:transmitted"`
}
type PingerResult struct {
PacketsRecv int
PacketsSent int
Rtts []time.Duration
}
func (r PingerResult) IsPingable() bool {
return r.PacketsRecv > 0 && r.PacketsSent > 0 && len(r.Rtts) > 0
}
func (r PingerResult) AverageRtt() time.Duration {
if len(r.Rtts) == 0 {
return 0
}
var total time.Duration
for _, rtt := range r.Rtts {
total += rtt
}
return total / time.Duration(len(r.Rtts))
}

View File

@@ -12,8 +12,8 @@ import (
"sync"
)
// SetupLogging initializes the global logger with the given level and format
func SetupLogging(level string, pretty, json bool) {
// GetLoggingHandler initializes a slog.Handler based on the provided logging level and format options.
func GetLoggingHandler(level string, pretty, json bool) slog.Handler {
var logLevel = new(slog.LevelVar)
switch strings.ToLower(level) {
@@ -46,6 +46,13 @@ func SetupLogging(level string, pretty, json bool) {
handler = slog.NewTextHandler(output, opts)
}
return handler
}
// SetupLogging initializes the global logger with the given level and format
func SetupLogging(level string, pretty, json bool) {
handler := GetLoggingHandler(level, pretty, json)
logger := slog.New(handler)
slog.SetDefault(logger)

View File

@@ -0,0 +1,435 @@
package lowlevel
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/config"
)
// region models
const (
MikrotikApiStatusOk = "success"
MikrotikApiStatusError = "error"
)
const (
MikrotikApiErrorCodeUnknown = iota + 600
MikrotikApiErrorCodeRequestPreparationFailed
MikrotikApiErrorCodeRequestFailed
MikrotikApiErrorCodeResponseDecodeFailed
)
type MikrotikApiResponse[T any] struct {
Status string
Code int
Data T `json:"data,omitempty"`
Error *MikrotikApiError `json:"error,omitempty"`
}
type MikrotikApiError struct {
Code int `json:"error,omitempty"`
Message string `json:"message,omitempty"`
Details string `json:"detail,omitempty"`
}
func (e *MikrotikApiError) String() string {
if e == nil {
return "no error"
}
return fmt.Sprintf("API error %d: %s - %s", e.Code, e.Message, e.Details)
}
type GenericJsonObject map[string]any
type EmptyResponse struct{}
func (JsonObject GenericJsonObject) GetString(key string) string {
if value, ok := JsonObject[key]; ok {
if strValue, ok := value.(string); ok {
return strValue
} else {
return fmt.Sprintf("%v", value) // Convert to string if not already
}
}
return ""
}
func (JsonObject GenericJsonObject) GetInt(key string) int {
if value, ok := JsonObject[key]; ok {
if intValue, ok := value.(int); ok {
return intValue
} else {
if floatValue, ok := value.(float64); ok {
return int(floatValue) // Convert float64 to int
}
if strValue, ok := value.(string); ok {
if intValue, err := strconv.Atoi(strValue); err == nil {
return intValue // Convert string to int if possible
}
}
}
}
return 0
}
func (JsonObject GenericJsonObject) GetBool(key string) bool {
if value, ok := JsonObject[key]; ok {
if boolValue, ok := value.(bool); ok {
return boolValue
} else {
if intValue, ok := value.(int); ok {
return intValue == 1 // Convert int to bool (1 is true, 0 is false)
}
if floatValue, ok := value.(float64); ok {
return int(floatValue) == 1 // Convert float64 to bool (1.0 is true, 0.0 is false)
}
if strValue, ok := value.(string); ok {
boolValue, err := strconv.ParseBool(strValue)
if err == nil {
return boolValue
}
}
}
}
return false
}
type MikrotikRequestOptions struct {
Filters map[string]string `json:"filters,omitempty"`
PropList []string `json:"proplist,omitempty"`
}
func (o *MikrotikRequestOptions) GetPath(base string) string {
if o == nil {
return base
}
path, err := url.Parse(base)
if err != nil {
return base
}
query := path.Query()
for k, v := range o.Filters {
query.Set(k, v)
}
if len(o.PropList) > 0 {
query.Set(".proplist", strings.Join(o.PropList, ","))
}
path.RawQuery = query.Encode()
return path.String()
}
// region models
// region API-client
type MikrotikApiClient struct {
coreCfg *config.Config
cfg *config.BackendMikrotik
client *http.Client
log *slog.Logger
}
func NewMikrotikApiClient(coreCfg *config.Config, cfg *config.BackendMikrotik) (*MikrotikApiClient, error) {
c := &MikrotikApiClient{
coreCfg: coreCfg,
cfg: cfg,
}
err := c.setup()
if err != nil {
return nil, err
}
c.debugLog("Mikrotik api client created", "api_url", cfg.ApiUrl)
return c, nil
}
func (m *MikrotikApiClient) setup() error {
m.client = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: !m.cfg.ApiVerifyTls,
},
},
Timeout: m.cfg.GetApiTimeout(),
}
if m.cfg.Debug {
m.log = slog.New(internal.GetLoggingHandler("debug",
m.coreCfg.Advanced.LogPretty,
m.coreCfg.Advanced.LogJson).
WithAttrs([]slog.Attr{
{
Key: "mikrotik-bid", Value: slog.StringValue(m.cfg.Id),
},
}))
}
return nil
}
func (m *MikrotikApiClient) debugLog(msg string, args ...any) {
if m.log != nil {
m.log.Debug("[MT-API] "+msg, args...)
}
}
func (m *MikrotikApiClient) getFullPath(command string) string {
path, err := url.JoinPath(m.cfg.ApiUrl, command)
if err != nil {
return ""
}
return path
}
func (m *MikrotikApiClient) prepareGetRequest(ctx context.Context, fullUrl string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullUrl, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
if m.cfg.ApiUser != "" && m.cfg.ApiPassword != "" {
req.SetBasicAuth(m.cfg.ApiUser, m.cfg.ApiPassword)
}
return req, nil
}
func (m *MikrotikApiClient) prepareDeleteRequest(ctx context.Context, fullUrl string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, fullUrl, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
if m.cfg.ApiUser != "" && m.cfg.ApiPassword != "" {
req.SetBasicAuth(m.cfg.ApiUser, m.cfg.ApiPassword)
}
return req, nil
}
func (m *MikrotikApiClient) preparePayloadRequest(
ctx context.Context,
method string,
fullUrl string,
payload GenericJsonObject,
) (*http.Request, error) {
// marshal the payload to JSON
payloadBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, method, fullUrl, bytes.NewReader(payloadBytes))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
if m.cfg.ApiUser != "" && m.cfg.ApiPassword != "" {
req.SetBasicAuth(m.cfg.ApiUser, m.cfg.ApiPassword)
}
return req, nil
}
func errToApiResponse[T any](code int, message string, err error) MikrotikApiResponse[T] {
return MikrotikApiResponse[T]{
Status: MikrotikApiStatusError,
Code: code,
Error: &MikrotikApiError{
Code: code,
Message: message,
Details: err.Error(),
},
}
}
func parseHttpResponse[T any](resp *http.Response, err error) MikrotikApiResponse[T] {
if err != nil {
return errToApiResponse[T](MikrotikApiErrorCodeRequestFailed, "failed to execute request", err)
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
slog.Error("failed to close response body", "error", err)
}
}(resp.Body)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
var data T
// if the type of T is EmptyResponse, we can return an empty response with just the status
if _, ok := any(data).(EmptyResponse); ok {
return MikrotikApiResponse[T]{Status: MikrotikApiStatusOk, Code: resp.StatusCode}
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return errToApiResponse[T](MikrotikApiErrorCodeResponseDecodeFailed, "failed to decode response", err)
}
return MikrotikApiResponse[T]{Status: MikrotikApiStatusOk, Code: resp.StatusCode, Data: data}
}
var apiErr MikrotikApiError
if err := json.NewDecoder(resp.Body).Decode(&apiErr); err != nil {
return errToApiResponse[T](resp.StatusCode, "unknown error, unparsable response", err)
} else {
return MikrotikApiResponse[T]{Status: MikrotikApiStatusError, Code: resp.StatusCode, Error: &apiErr}
}
}
func (m *MikrotikApiClient) Query(
ctx context.Context,
command string,
opts *MikrotikRequestOptions,
) MikrotikApiResponse[[]GenericJsonObject] {
apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout())
defer cancel()
fullUrl := opts.GetPath(m.getFullPath(command))
req, err := m.prepareGetRequest(apiCtx, fullUrl)
if err != nil {
return errToApiResponse[[]GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
m.debugLog("executing API query", "url", fullUrl)
response := parseHttpResponse[[]GenericJsonObject](m.client.Do(req))
m.debugLog("retrieved API query result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
func (m *MikrotikApiClient) Get(
ctx context.Context,
command string,
opts *MikrotikRequestOptions,
) MikrotikApiResponse[GenericJsonObject] {
apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout())
defer cancel()
fullUrl := opts.GetPath(m.getFullPath(command))
req, err := m.prepareGetRequest(apiCtx, fullUrl)
if err != nil {
return errToApiResponse[GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
m.debugLog("executing API get", "url", fullUrl)
response := parseHttpResponse[GenericJsonObject](m.client.Do(req))
m.debugLog("retrieved API get result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
func (m *MikrotikApiClient) Create(
ctx context.Context,
command string,
payload GenericJsonObject,
) MikrotikApiResponse[GenericJsonObject] {
apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout())
defer cancel()
fullUrl := m.getFullPath(command)
req, err := m.preparePayloadRequest(apiCtx, http.MethodPut, fullUrl, payload)
if err != nil {
return errToApiResponse[GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
m.debugLog("executing API put", "url", fullUrl)
response := parseHttpResponse[GenericJsonObject](m.client.Do(req))
m.debugLog("retrieved API put result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
func (m *MikrotikApiClient) Update(
ctx context.Context,
command string,
payload GenericJsonObject,
) MikrotikApiResponse[GenericJsonObject] {
apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout())
defer cancel()
fullUrl := m.getFullPath(command)
req, err := m.preparePayloadRequest(apiCtx, http.MethodPatch, fullUrl, payload)
if err != nil {
return errToApiResponse[GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
m.debugLog("executing API patch", "url", fullUrl)
response := parseHttpResponse[GenericJsonObject](m.client.Do(req))
m.debugLog("retrieved API patch result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
func (m *MikrotikApiClient) Delete(
ctx context.Context,
command string,
) MikrotikApiResponse[EmptyResponse] {
apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout())
defer cancel()
fullUrl := m.getFullPath(command)
req, err := m.prepareDeleteRequest(apiCtx, fullUrl)
if err != nil {
return errToApiResponse[EmptyResponse](MikrotikApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
m.debugLog("executing API delete", "url", fullUrl)
response := parseHttpResponse[EmptyResponse](m.client.Do(req))
m.debugLog("retrieved API delete result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
func (m *MikrotikApiClient) ExecList(
ctx context.Context,
command string,
payload GenericJsonObject,
) MikrotikApiResponse[[]GenericJsonObject] {
apiCtx, cancel := context.WithTimeout(ctx, m.cfg.GetApiTimeout())
defer cancel()
fullUrl := m.getFullPath(command)
req, err := m.preparePayloadRequest(apiCtx, http.MethodPost, fullUrl, payload)
if err != nil {
return errToApiResponse[[]GenericJsonObject](MikrotikApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
m.debugLog("executing API post", "url", fullUrl)
response := parseHttpResponse[[]GenericJsonObject](m.client.Do(req))
m.debugLog("retrieved API post result", "url", fullUrl, "duration", time.Since(start).String())
return response
}
// endregion API-client

View File

@@ -80,6 +80,7 @@ nav:
- Examples: documentation/configuration/examples.md
- Usage:
- General: documentation/usage/general.md
- Backends: documentation/usage/backends.md
- LDAP: documentation/usage/ldap.md
- Security: documentation/usage/security.md
- Webhooks: documentation/usage/webhooks.md