Merge branch 'master' into mikrotik_integration

# Conflicts:
#	internal/app/api/v0/handlers/endpoint_config.go
#	internal/app/api/v0/model/models.go
#	internal/app/wireguard/statistics.go
#	internal/app/wireguard/wireguard_interfaces.go
This commit is contained in:
Christoph Haas 2025-07-29 22:16:00 +02:00
commit ed7761a918
No known key found for this signature in database
62 changed files with 1383 additions and 378 deletions

View File

@ -28,3 +28,8 @@ updates:
patch: patch:
update-types: update-types:
- patch - patch
- package-ecosystem: "docker"
directory: /
schedule:
interval: weekly

View File

@ -50,7 +50,7 @@ COPY --from=builder /build/dist/wg-portal /
###### ######
# Final image # Final image
###### ######
FROM alpine:3.19 FROM alpine:3.22
# Install OS-level dependencies # Install OS-level dependencies
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools
# Setup timezone # Setup timezone

View File

@ -88,6 +88,7 @@ func main() {
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager) authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager)
internal.AssertNoError(err) internal.AssertNoError(err)
authenticator.StartBackgroundJobs(ctx)
webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager) webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager)
internal.AssertNoError(err) internal.AssertNoError(err)

View File

@ -38,6 +38,7 @@ advanced:
rule_prio_offset: 20000 rule_prio_offset: 20000
route_table_offset: 20000 route_table_offset: 20000
api_admin_only: true api_admin_only: true
limit_additional_user_peers: 0
database: database:
debug: false debug: false
@ -75,6 +76,7 @@ auth:
webauthn: webauthn:
enabled: true enabled: true
min_password_length: 16 min_password_length: 16
hide_login_form: false
web: web:
listening_address: :8888 listening_address: :8888
@ -215,6 +217,10 @@ Additional or more specialized configuration options for logging and interface c
- **Default:** `true` - **Default:** `true`
- **Description:** If `true`, the public REST API is accessible only to admin users. The API docs live at [`/api/v1/doc.html`](../rest-api/api-doc.md). - **Description:** If `true`, the public REST API is accessible only to admin users. The API docs live at [`/api/v1/doc.html`](../rest-api/api-doc.md).
### `limit_additional_user_peers`
- **Default:** `0`
- **Description:** Limit additional peers a normal user can create. `0` means unlimited.
--- ---
## Database ## Database
@ -349,6 +355,12 @@ Some core authentication options are shared across all providers, while others a
The default admin password strength is also enforced by this setting. The default admin password strength is also enforced by this setting.
- **Important:** The password should be strong and secure. It is recommended to use a password with at least 16 characters, including uppercase and lowercase letters, numbers, and special characters. - **Important:** The password should be strong and secure. It is recommended to use a password with at least 16 characters, including uppercase and lowercase letters, numbers, and special characters.
### `hide_login_form`
- **Default:** `false`
- **Description:** If `true`, the login form is hidden and only the OIDC, OAuth, LDAP, or WebAuthn providers are shown. This is useful if you want to enforce a specific authentication method.
If no social login providers are configured, the login form is always shown, regardless of this setting.
- **Important:** You can still access the login form by adding the `?all` query parameter to the login URL (e.g. https://wg.portal/#/login?all).
--- ---
### OIDC ### OIDC
@ -661,18 +673,7 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
## Webhook ## Webhook
The webhook section allows you to configure a webhook that is called on certain events in WireGuard Portal. The webhook section allows you to configure a webhook that is called on certain events in WireGuard Portal.
A JSON object is sent in a POST request to the webhook URL with the following structure: Further details can be found in the [usage documentation](../usage/webhooks.md).
```json
{
"event": "peer_created",
"entity": "peer",
"identifier": "the-peer-identifier",
"payload": {
// The payload of the event, e.g. peer data.
// Check the API documentation for the exact structure.
}
}
```
### `url` ### `url`
- **Default:** *(empty)* - **Default:** *(empty)*

View File

@ -0,0 +1,285 @@
Webhooks allow WireGuard Portal to notify external services about events such as user creation, device changes, or configuration updates. This enables integration with other systems and automation workflows.
When webhooks are configured and a specified event occurs, WireGuard Portal sends an HTTP **POST** request to the configured webhook URL.
The payload contains event-specific data in JSON format.
## Configuration
All available configuration options for webhooks can be found in the [configuration overview](../configuration/overview.md#webhook).
A basic webhook configuration looks like this:
```yaml
webhook:
url: https://your-service.example.com/webhook
```
### Security
Webhooks can be secured by using a shared secret. This secret is included in the `Authorization` header of the webhook request, allowing your service to verify the authenticity of the request.
You can set the shared secret in the webhook configuration:
```yaml
webhook:
url: https://your-service.example.com/webhook
secret: "Basic dXNlcm5hbWU6cGFzc3dvcmQ="
```
You should also make sure that your webhook endpoint is secured with HTTPS to prevent eavesdropping and tampering.
## Available Events
WireGuard Portal supports various events that can trigger webhooks. The following events are available:
- `create`: Triggered when a new entity is created.
- `update`: Triggered when an existing entity is updated.
- `delete`: Triggered when an entity is deleted.
- `connect`: Triggered when a user connects to the VPN.
- `disconnect`: Triggered when a user disconnects from the VPN.
The following entity models are supported for webhook events:
- `user`: WireGuard Portal users support creation, update, or deletion events.
- `peer`: Peers support creation, update, or deletion events. Via the `peer_metric` entity, you can also receive connection status updates.
- `peer_metric`: Peer metrics support connection status updates, such as when a peer connects or disconnects.
- `interface`: WireGuard interfaces support creation, update, or deletion events.
## Payload Structure
All webhook events send a JSON payload containing relevant data. The structure of the payload depends on the event type and entity involved.
A common shell structure for webhook payloads is as follows:
```json
{
"event": "create", // The event type, e.g. "create", "update", "delete", "connect", "disconnect"
"entity": "user", // The entity type, e.g. "user", "peer", "peer_metric", "interface"
"identifier": "the-user-identifier", // Unique identifier of the entity, e.g. user ID or peer ID
"payload": {
// The payload of the event, e.g. a Peer model.
// Detailed model descriptions are provided below.
}
}
```
### Payload Models
All payload models are encoded as JSON objects. Fields with empty values might be omitted in the payload.
#### User Payload (entity: `user`)
| JSON Field | Type | Description |
|----------------|-------------|-----------------------------------|
| CreatedBy | string | Creator identifier |
| UpdatedBy | string | Last updater identifier |
| CreatedAt | time.Time | Time of creation |
| UpdatedAt | time.Time | Time of last update |
| Identifier | string | Unique user identifier |
| Email | string | User email |
| Source | string | Authentication source |
| ProviderName | string | Name of auth provider |
| IsAdmin | bool | Whether user has admin privileges |
| Firstname | string | User's first name (optional) |
| Lastname | string | User's last name (optional) |
| Phone | string | Contact phone number (optional) |
| Department | string | User's department (optional) |
| Notes | string | Additional notes (optional) |
| Disabled | *time.Time | When user was disabled |
| DisabledReason | string | Reason for deactivation |
| Locked | *time.Time | When user account was locked |
| LockedReason | string | Reason for being locked |
#### Peer Payload (entity: `peer`)
| JSON Field | Type | Description |
|----------------------|------------|----------------------------------------|
| CreatedBy | string | Creator identifier |
| UpdatedBy | string | Last updater identifier |
| CreatedAt | time.Time | Creation timestamp |
| UpdatedAt | time.Time | Last update timestamp |
| Endpoint | string | Peer endpoint address |
| EndpointPublicKey | string | Public key of peer endpoint |
| AllowedIPsStr | string | Allowed IPs |
| ExtraAllowedIPsStr | string | Extra allowed IPs |
| PresharedKey | string | Pre-shared key for encryption |
| PersistentKeepalive | int | Keepalive interval in seconds |
| DisplayName | string | Display name of the peer |
| Identifier | string | Unique identifier |
| UserIdentifier | string | Associated user ID (optional) |
| InterfaceIdentifier | string | Interface this peer is attached to |
| Disabled | *time.Time | When the peer was disabled |
| DisabledReason | string | Reason for being disabled |
| ExpiresAt | *time.Time | Expiration date |
| Notes | string | Notes for this peer |
| AutomaticallyCreated | bool | Whether peer was auto-generated |
| PrivateKey | string | Peer private key |
| PublicKey | string | Peer public key |
| InterfaceType | string | Type of the peer interface |
| Addresses | []string | IP addresses |
| CheckAliveAddress | string | Address used for alive checks |
| DnsStr | string | DNS servers |
| DnsSearchStr | string | DNS search domains |
| Mtu | int | MTU (Maximum Transmission Unit) |
| FirewallMark | uint32 | Firewall mark (optional) |
| RoutingTable | string | Custom routing table (optional) |
| PreUp | string | Command before bringing up interface |
| PostUp | string | Command after bringing up interface |
| PreDown | string | Command before bringing down interface |
| PostDown | string | Command after bringing down interface |
#### Interface Payload (entity: `interface`)
| JSON Field | Type | Description |
|----------------------------|------------|----------------------------------------|
| CreatedBy | string | Creator identifier |
| UpdatedBy | string | Last updater identifier |
| CreatedAt | time.Time | Creation timestamp |
| UpdatedAt | time.Time | Last update timestamp |
| Identifier | string | Unique identifier |
| PrivateKey | string | Private key for the interface |
| PublicKey | string | Public key for the interface |
| ListenPort | int | Listening port |
| Addresses | []string | IP addresses |
| DnsStr | string | DNS servers |
| DnsSearchStr | string | DNS search domains |
| Mtu | int | MTU (Maximum Transmission Unit) |
| FirewallMark | uint32 | Firewall mark |
| RoutingTable | string | Custom routing table |
| PreUp | string | Command before bringing up interface |
| PostUp | string | Command after bringing up interface |
| PreDown | string | Command before bringing down interface |
| PostDown | string | Command after bringing down interface |
| SaveConfig | bool | Whether to save config to file |
| DisplayName | string | Human-readable name |
| Type | string | Type of interface |
| DriverType | string | Driver used |
| Disabled | *time.Time | When the interface was disabled |
| DisabledReason | string | Reason for being disabled |
| PeerDefNetworkStr | string | Default peer network configuration |
| PeerDefDnsStr | string | Default peer DNS servers |
| PeerDefDnsSearchStr | string | Default peer DNS search domains |
| PeerDefEndpoint | string | Default peer endpoint |
| PeerDefAllowedIPsStr | string | Default peer allowed IPs |
| PeerDefMtu | int | Default peer MTU |
| PeerDefPersistentKeepalive | int | Default keepalive value |
| PeerDefFirewallMark | uint32 | Default firewall mark for peers |
| PeerDefRoutingTable | string | Default routing table for peers |
| PeerDefPreUp | string | Default peer pre-up command |
| PeerDefPostUp | string | Default peer post-up command |
| PeerDefPreDown | string | Default peer pre-down command |
| PeerDefPostDown | string | Default peer post-down command |
#### Peer Metrics Payload (entity: `peer_metric`)
| JSON Field | Type | Description |
|------------|------------|----------------------------|
| Status | PeerStatus | Current status of the peer |
| Peer | Peer | Peer data |
`PeerStatus` sub-structure:
| JSON Field | Type | Description |
|------------------|------------|------------------------------|
| UpdatedAt | time.Time | Time of last status update |
| IsConnected | bool | Is peer currently connected |
| IsPingable | bool | Can peer be pinged |
| LastPing | *time.Time | Time of last successful ping |
| BytesReceived | uint64 | Bytes received from peer |
| BytesTransmitted | uint64 | Bytes sent to peer |
| Endpoint | string | Last known endpoint |
| LastHandshake | *time.Time | Last successful handshake |
| LastSessionStart | *time.Time | Time the last session began |
### Example Payloads
The following payload is an example of a webhook event when a peer connects to the VPN:
```json
{
"event": "connect",
"entity": "peer_metric",
"identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
"payload": {
"Status": {
"UpdatedAt": "2025-06-27T22:20:08.734900034+02:00",
"IsConnected": true,
"IsPingable": false,
"BytesReceived": 212,
"BytesTransmitted": 2884,
"Endpoint": "10.55.66.77:58756",
"LastHandshake": "2025-06-27T22:19:46.580842776+02:00",
"LastSessionStart": "2025-06-27T22:19:46.580842776+02:00"
},
"Peer": {
"CreatedBy": "admin@wgportal.local",
"UpdatedBy": "admin@wgportal.local",
"CreatedAt": "2025-06-26T21:43:49.251839574+02:00",
"UpdatedAt": "2025-06-27T22:18:39.67763985+02:00",
"Endpoint": "10.55.66.1:51820",
"EndpointPublicKey": "eiVibpi3C2PUPcx2kwA5s09OgHx7AEaKMd33k0LQ5mM=",
"AllowedIPsStr": "10.11.12.0/24,fdfd:d3ad:c0de:1234::/64",
"ExtraAllowedIPsStr": "",
"PresharedKey": "p9DDeLUSLOdQcjS8ZsBAiqUzwDIUvTyzavRZFuzhvyE=",
"PersistentKeepalive": 16,
"DisplayName": "Peer Fb5TaziA",
"Identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
"UserIdentifier": "admin@wgportal.local",
"InterfaceIdentifier": "wgTesting",
"AutomaticallyCreated": false,
"PrivateKey": "QBFNBe+7J49ergH0ze2TGUJMFrL/2bOL50Z2cgluYW8=",
"PublicKey": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
"InterfaceType": "client",
"Addresses": [
"10.11.12.10/32",
"fdfd:d3ad:c0de:1234::a/128"
],
"CheckAliveAddress": "",
"DnsStr": "",
"DnsSearchStr": "",
"Mtu": 1420
}
}
}
```
Here is another example of a webhook event when a peer is updated:
```json
{
"event": "update",
"entity": "peer",
"identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
"payload": {
"CreatedBy": "admin@wgportal.local",
"UpdatedBy": "admin@wgportal.local",
"CreatedAt": "2025-06-26T21:43:49.251839574+02:00",
"UpdatedAt": "2025-06-27T22:18:39.67763985+02:00",
"Endpoint": "10.55.66.1:51820",
"EndpointPublicKey": "eiVibpi3C2PUPcx2kwA5s09OgHx7AEaKMd33k0LQ5mM=",
"AllowedIPsStr": "10.11.12.0/24,fdfd:d3ad:c0de:1234::/64",
"ExtraAllowedIPsStr": "",
"PresharedKey": "p9DDeLUSLOdQcjS8ZsBAiqUzwDIUvTyzavRZFuzhvyE=",
"PersistentKeepalive": 16,
"DisplayName": "Peer Fb5TaziA",
"Identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
"UserIdentifier": "admin@wgportal.local",
"InterfaceIdentifier": "wgTesting",
"AutomaticallyCreated": false,
"PrivateKey": "QBFNBe+7J49ergH0ze2TGUJMFrL/2bOL50Z2cgluYW8=",
"PublicKey": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
"InterfaceType": "client",
"Addresses": [
"10.11.12.10/32",
"fdfd:d3ad:c0de:1234::a/128"
],
"CheckAliveAddress": "",
"DnsStr": "",
"DnsSearchStr": "",
"Mtu": 1420
}
}
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -50,7 +50,7 @@ const selectedStats = computed(() => {
if (!s) { if (!s) {
if (!!props.peerId || props.peerId.length) { if (!!props.peerId || props.peerId.length) {
p = profile.Statistics(props.peerId) s = profile.Statistics(props.peerId)
} else { } else {
s = freshStats() // dummy stats to avoid 'undefined' exceptions s = freshStats() // dummy stats to avoid 'undefined' exceptions
} }
@ -79,13 +79,19 @@ const title = computed(() => {
} }
}) })
const configStyle = ref("wgquick")
watch(() => props.visible, async (newValue, oldValue) => { watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown if (oldValue === false && newValue === true) { // if modal is shown
await peers.LoadPeerConfig(selectedPeer.value.Identifier) await peers.LoadPeerConfig(selectedPeer.value.Identifier, configStyle.value)
configString.value = peers.configuration configString.value = peers.configuration
} }
} })
)
watch(() => configStyle.value, async () => {
await peers.LoadPeerConfig(selectedPeer.value.Identifier, configStyle.value)
configString.value = peers.configuration
})
function download() { function download() {
// credit: https://www.bitdegree.org/learn/javascript-download // credit: https://www.bitdegree.org/learn/javascript-download
@ -103,7 +109,7 @@ function download() {
} }
function email() { function email() {
peers.MailPeerConfig(settings.Setting("MailLinkOnly"), [selectedPeer.value.Identifier]).catch(e => { peers.MailPeerConfig(settings.Setting("MailLinkOnly"), configStyle.value, [selectedPeer.value.Identifier]).catch(e => {
notify({ notify({
title: "Failed to send mail with peer configuration!", title: "Failed to send mail with peer configuration!",
text: e.toString(), text: e.toString(),
@ -114,7 +120,7 @@ function email() {
function ConfigQrUrl() { function ConfigQrUrl() {
if (props.peerId.length) { if (props.peerId.length) {
return apiWrapper.url(`/peer/config-qr/${base64_url_encode(props.peerId)}`) return apiWrapper.url(`/peer/config-qr/${base64_url_encode(props.peerId)}?style=${configStyle.value}`)
} }
return '' return ''
} }
@ -124,6 +130,15 @@ function ConfigQrUrl() {
<template> <template>
<Modal :title="title" :visible="visible" @close="close"> <Modal :title="title" :visible="visible" @close="close">
<template #default> <template #default>
<div class="d-flex justify-content-end align-items-center mb-1">
<span class="me-2">{{ $t('modals.peer-view.style-label') }}: </span>
<div class="btn-group btn-switch-group" role="group" aria-label="Configuration Style">
<input type="radio" class="btn-check" name="configstyle" id="raw" value="raw" autocomplete="off" checked="" v-model="configStyle">
<label class="btn btn-outline-dark btn-sm" for="raw">Raw</label>
<input type="radio" class="btn-check" name="configstyle" id="wgquick" value="wgquick" autocomplete="off" checked="" v-model="configStyle">
<label class="btn btn-outline-dark btn-sm" for="wgquick">WG-Quick</label>
</div>
</div>
<div class="accordion" id="peerInformation"> <div class="accordion" id="peerInformation">
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
@ -213,6 +228,14 @@ function ConfigQrUrl() {
</template> </template>
</Modal></template> </Modal></template>
<style>.config-qr-img { <style>
.config-qr-img {
max-width: 100%; max-width: 100%;
}</style> }
.btn-switch-group .btn {
border-width: 1px;
padding: 5px;
line-height: 1;
}
</style>

View File

@ -474,7 +474,8 @@
"connected-since": "Verbunden seit", "connected-since": "Verbunden seit",
"endpoint": "Endpunkt", "endpoint": "Endpunkt",
"button-download": "Konfiguration herunterladen", "button-download": "Konfiguration herunterladen",
"button-email": "Konfiguration per E-Mail senden" "button-email": "Konfiguration per E-Mail senden",
"style-label": "Konfigurationsformat"
}, },
"peer-edit": { "peer-edit": {
"headline-edit-peer": "Peer bearbeiten:", "headline-edit-peer": "Peer bearbeiten:",

View File

@ -475,7 +475,8 @@
"connected-since": "Connected since", "connected-since": "Connected since",
"endpoint": "Endpoint", "endpoint": "Endpoint",
"button-download": "Download configuration", "button-download": "Download configuration",
"button-email": "Send configuration via E-Mail" "button-email": "Send configuration via E-Mail",
"style-label": "Configuration Style"
}, },
"peer-edit": { "peer-edit": {
"headline-edit-peer": "Edit peer:", "headline-edit-peer": "Edit peer:",

View File

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

View File

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

View File

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

View File

@ -142,7 +142,7 @@ onMounted(async () => {
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-group mb-3"> <div class="input-group mb-3">
<button class="input-group-text btn btn-primary" :title="$t('interfaces.button-add-interface')" @click.prevent="editInterfaceId='#NEW#'"> <button class="btn btn-primary" :title="$t('interfaces.button-add-interface')" @click.prevent="editInterfaceId='#NEW#'">
<i class="fa-solid fa-plus-circle"></i> <i class="fa-solid fa-plus-circle"></i>
</button> </button>
<select v-model="interfaces.selected" :disabled="interfaces.Count===0" class="form-select" @change="() => { peers.LoadPeers(); peers.LoadStats() }"> <select v-model="interfaces.selected" :disabled="interfaces.Count===0" class="form-select" @change="() => { peers.LoadPeers(); peers.LoadStats() }">
@ -344,7 +344,7 @@ onMounted(async () => {
<div class="form-group d-inline"> <div class="form-group d-inline">
<div class="input-group mb-3"> <div class="input-group mb-3">
<input v-model="peers.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="peers.afterPageSizeChange"> <input v-model="peers.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="peers.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button> <button class="btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
</div> </div>
</div> </div>
</div> </div>
@ -459,3 +459,5 @@ onMounted(async () => {
</div> </div>
</div> </div>
</template> </template>
<style>
</style>

View File

@ -16,7 +16,10 @@ const password = ref("")
const usernameInvalid = computed(() => username.value === "") const usernameInvalid = computed(() => username.value === "")
const passwordInvalid = computed(() => password.value === "") const passwordInvalid = computed(() => password.value === "")
const disableLoginBtn = computed(() => username.value === "" || password.value === "" || loggingIn.value) const disableLoginBtn = computed(() => username.value === "" || password.value === "" || loggingIn.value)
const showLoginForm = computed(() => {
console.log(router.currentRoute.value.query)
return settings.Setting('LoginFormVisible') || router.currentRoute.value.query.hasOwnProperty('all');
});
onMounted(async () => { onMounted(async () => {
await settings.LoadSettings() await settings.LoadSettings()
@ -98,7 +101,7 @@ const externalLogin = function (provider) {
</div></div> </div></div>
<div class="card-body"> <div class="card-body">
<form method="post"> <form method="post">
<fieldset> <fieldset v-if="showLoginForm">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="inputUsername">{{ $t('login.username.label') }}</label> <label class="form-label" for="inputUsername">{{ $t('login.username.label') }}</label>
<div class="input-group mb-3"> <div class="input-group mb-3">
@ -118,19 +121,40 @@ const externalLogin = function (provider) {
</div> </div>
<div class="row mt-5 mb-2"> <div class="row mt-5 mb-2">
<div class="col-lg-4"> <div class="col-sm-4 col-xs-12">
<button :disabled="disableLoginBtn" class="btn btn-primary" type="submit" @click.prevent="login"> <button :disabled="disableLoginBtn" class="btn btn-primary mb-2" type="submit" @click.prevent="login">
{{ $t('login.button') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div> {{ $t('login.button') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
</button> </button>
</div> </div>
<div class="col-lg-8 mb-2 text-end"> <div class="col-sm-8 col-xs-12 text-sm-end">
<button v-if="settings.Setting('WebAuthnEnabled')" class="btn btn-primary" type="submit" @click.prevent="loginWebAuthn"> <button v-if="settings.Setting('WebAuthnEnabled')" class="btn btn-primary" type="submit" @click.prevent="loginWebAuthn">
{{ $t('login.button-webauthn') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div> {{ $t('login.button-webauthn') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
</button> </button>
</div> </div>
</div> </div>
<div class="row mt-5 d-flex"> <div class="row mt-4 d-flex">
<div class="col-lg-12 d-flex mb-2">
<!-- OpenIdConnect / OAUTH providers -->
<button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}"
:disabled="loggingIn" :title="provider.Name" class="btn btn-outline-primary flex-fill"
v-html="provider.Name" @click.prevent="externalLogin(provider)"></button>
</div>
</div>
<div class="mt-3">
</div>
</fieldset>
<fieldset v-else>
<div class="row mt-1 mb-2" v-if="settings.Setting('WebAuthnEnabled')">
<div class="col-lg-12 d-flex mb-2">
<button class="btn btn-outline-primary flex-fill" type="submit" @click.prevent="loginWebAuthn">
{{ $t('login.button-webauthn') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
</button>
</div>
</div>
<div class="row mt-1 d-flex">
<div class="col-lg-12 d-flex mb-2"> <div class="col-lg-12 d-flex mb-2">
<!-- OpenIdConnect / OAUTH providers --> <!-- OpenIdConnect / OAUTH providers -->
<button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}" <button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}"
@ -144,7 +168,6 @@ const externalLogin = function (provider) {
</fieldset> </fieldset>
</form> </form>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

39
go.mod
View File

@ -4,32 +4,32 @@ go 1.24.0
require ( require (
github.com/a8m/envsubst v1.4.3 github.com/a8m/envsubst v1.4.3
github.com/alexedwards/scs/v2 v2.8.0 github.com/alexedwards/scs/v2 v2.9.0
github.com/coreos/go-oidc/v3 v3.14.1 github.com/coreos/go-oidc/v3 v3.14.1
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-ldap/ldap/v3 v3.4.11
github.com/go-pkgz/routegroup v1.4.1 github.com/go-pkgz/routegroup v1.4.1
github.com/go-playground/validator/v10 v10.26.0 github.com/go-playground/validator/v10 v10.27.0
github.com/go-webauthn/webauthn v0.12.3 github.com/go-webauthn/webauthn v0.13.4
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/prometheus-community/pro-bing v0.7.0 github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus/client_golang v1.22.0 github.com/prometheus/client_golang v1.22.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/swaggo/swag v1.16.4 github.com/swaggo/swag v1.16.5
github.com/vardius/message-bus v1.1.5 github.com/vardius/message-bus v1.1.5
github.com/vishvananda/netlink v1.3.1 github.com/vishvananda/netlink v1.3.1
github.com/xhit/go-simple-mail/v2 v2.16.0 github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.5 github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1 github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.38.0 golang.org/x/crypto v0.40.0
golang.org/x/oauth2 v0.30.0 golang.org/x/oauth2 v0.30.0
golang.org/x/sys v0.33.0 golang.org/x/sys v0.34.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7 gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.5.11 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlserver v1.5.4 gorm.io/driver/sqlserver v1.6.1
gorm.io/gorm v1.26.1 gorm.io/gorm v1.30.0
) )
require ( require (
@ -40,7 +40,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/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/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
@ -53,12 +53,12 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/go-test/deep v1.1.1 // indirect github.com/go-test/deep v1.1.1 // indirect
github.com/go-webauthn/x v0.1.20 // indirect github.com/go-webauthn/x v0.1.23 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // 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/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-tpm v0.9.3 // indirect github.com/google/go-tpm v0.9.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.4 // indirect github.com/jackc/pgx/v5 v5.7.4 // indirect
@ -73,7 +73,7 @@ require (
github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect github.com/mdlayher/socket v0.5.1 // indirect
github.com/microsoft/go-mssqldb v1.8.0 // indirect github.com/microsoft/go-mssqldb v1.8.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
@ -87,10 +87,11 @@ require (
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/net v0.39.0 // indirect golang.org/x/mod v0.25.0 // indirect
golang.org/x/sync v0.14.0 // indirect golang.org/x/net v0.41.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/tools v0.32.0 // indirect golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
google.golang.org/protobuf v1.36.6 // indirect google.golang.org/protobuf v1.36.6 // indirect
modernc.org/libc v1.63.0 // indirect modernc.org/libc v1.63.0 // indirect

151
go.sum
View File

@ -2,15 +2,13 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI= github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo= github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw=
@ -20,7 +18,6 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
@ -29,14 +26,15 @@ github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY=
github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU= github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 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.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= github.com/coreos/go-oidc/v3 v3.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.14.1/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -44,8 +42,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/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 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 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
@ -74,34 +72,34 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= 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-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE= github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
github.com/go-webauthn/webauthn v0.12.3/go.mod h1:4JRe8Z3W7HIw8NGEWn2fnUwecoDzkkeach/NnvhkqGY= github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
github.com/go-webauthn/x v0.1.20 h1:brEBDqfiPtNNCdS/peu8gARtq8fIPsHz0VzpPjGvgiw= github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
github.com/go-webauthn/x v0.1.20/go.mod h1:n/gAc8ssZJGATM0qThE+W+vfgXiMedsWi3wf/C4lld0= 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.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc= github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= 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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
@ -139,8 +137,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/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@ -157,9 +158,8 @@ 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/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 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/microsoft/go-mssqldb v1.8.2 h1:236sewazvC8FvG6Dr3bszrVhMkAl4KYImryLkRMCd0I=
github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw= github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
@ -173,6 +173,7 @@ github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA= github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
@ -187,11 +188,13 @@ github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2b
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@ -199,10 +202,11 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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.5 h1:nMf2fEV1TetMTJb4XzD0Lz7jFfKJmJKGTygEey8NSxM=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= github.com/swaggo/swag v1.16.5/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA= github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
@ -228,18 +232,25 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 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/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@ -250,18 +261,26 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 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/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -275,11 +294,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -287,9 +311,13 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@ -300,14 +328,19 @@ golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= golang.zx2c4.com/wireguard v0.0.0-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-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
@ -325,16 +358,14 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g= gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY=
gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g= gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA= modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 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 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=

View File

@ -133,5 +133,5 @@ func (m *MetricsServer) UpdatePeerMetrics(peer *domain.Peer, status domain.PeerS
} }
m.peerReceivedBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesReceived)) m.peerReceivedBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesReceived))
m.peerSendBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesTransmitted)) m.peerSendBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesTransmitted))
m.peerIsConnected.WithLabelValues(labels...).Set(internal.BoolToFloat64(status.IsConnected())) m.peerIsConnected.WithLabelValues(labels...).Set(internal.BoolToFloat64(status.IsConnected))
} }

View File

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

View File

@ -57,6 +57,52 @@
} }
} }
}, },
"/auth/login/{provider}/callback": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Authentication"
],
"summary": "Handle the OAuth callback.",
"operationId": "auth_handleOauthCallbackGet",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.LoginProviderInfo"
}
}
}
}
}
},
"/auth/login/{provider}/init": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Authentication"
],
"summary": "Initiate the OAuth login flow.",
"operationId": "auth_handleOauthInitiateGet",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.LoginProviderInfo"
}
}
}
}
}
},
"/auth/logout": { "/auth/logout": {
"post": { "post": {
"produces": [ "produces": [
@ -275,52 +321,6 @@
} }
} }
}, },
"/auth/{provider}/callback": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Authentication"
],
"summary": "Handle the OAuth callback.",
"operationId": "auth_handleOauthCallbackGet",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.LoginProviderInfo"
}
}
}
}
}
},
"/auth/{provider}/init": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Authentication"
],
"summary": "Initiate the OAuth login flow.",
"operationId": "auth_handleOauthInitiateGet",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.LoginProviderInfo"
}
}
}
}
}
},
"/config/frontend.js": { "/config/frontend.js": {
"get": { "get": {
"produces": [ "produces": [
@ -819,6 +819,12 @@
"schema": { "schema": {
"$ref": "#/definitions/model.PeerMailRequest" "$ref": "#/definitions/model.PeerMailRequest"
} }
},
{
"type": "string",
"description": "The configuration style",
"name": "style",
"in": "query"
} }
], ],
"responses": { "responses": {
@ -858,6 +864,12 @@
"name": "id", "name": "id",
"in": "path", "in": "path",
"required": true "required": true
},
{
"type": "string",
"description": "The configuration style",
"name": "style",
"in": "query"
} }
], ],
"responses": { "responses": {
@ -899,6 +911,12 @@
"name": "id", "name": "id",
"in": "path", "in": "path",
"required": true "required": true
},
{
"type": "string",
"description": "The configuration style",
"name": "style",
"in": "query"
} }
], ],
"responses": { "responses": {
@ -2231,9 +2249,15 @@
"ApiAdminOnly": { "ApiAdminOnly": {
"type": "boolean" "type": "boolean"
}, },
"LoginFormVisible": {
"type": "boolean"
},
"MailLinkOnly": { "MailLinkOnly": {
"type": "boolean" "type": "boolean"
}, },
"MinPasswordLength": {
"type": "integer"
},
"PersistentConfigSupported": { "PersistentConfigSupported": {
"type": "boolean" "type": "boolean"
}, },

View File

@ -381,8 +381,12 @@ definitions:
properties: properties:
ApiAdminOnly: ApiAdminOnly:
type: boolean type: boolean
LoginFormVisible:
type: boolean
MailLinkOnly: MailLinkOnly:
type: boolean type: boolean
MinPasswordLength:
type: integer
PersistentConfigSupported: PersistentConfigSupported:
type: boolean type: boolean
SelfProvisioning: SelfProvisioning:
@ -472,7 +476,22 @@ paths:
summary: Get all available audit entries. Ordered by timestamp. summary: Get all available audit entries. Ordered by timestamp.
tags: tags:
- Audit - Audit
/auth/{provider}/callback: /auth/login:
post:
operationId: auth_handleLoginPost
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.LoginProviderInfo'
type: array
summary: Get all available external login providers.
tags:
- Authentication
/auth/login/{provider}/callback:
get: get:
operationId: auth_handleOauthCallbackGet operationId: auth_handleOauthCallbackGet
produces: produces:
@ -487,7 +506,7 @@ paths:
summary: Handle the OAuth callback. summary: Handle the OAuth callback.
tags: tags:
- Authentication - Authentication
/auth/{provider}/init: /auth/login/{provider}/init:
get: get:
operationId: auth_handleOauthInitiateGet operationId: auth_handleOauthInitiateGet
produces: produces:
@ -502,21 +521,6 @@ paths:
summary: Initiate the OAuth login flow. summary: Initiate the OAuth login flow.
tags: tags:
- Authentication - Authentication
/auth/login:
post:
operationId: auth_handleLoginPost
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.LoginProviderInfo'
type: array
summary: Get all available external login providers.
tags:
- Authentication
/auth/logout: /auth/logout:
post: post:
operationId: auth_handleLogoutPost operationId: auth_handleLogoutPost
@ -1068,6 +1072,10 @@ paths:
required: true required: true
schema: schema:
$ref: '#/definitions/model.PeerMailRequest' $ref: '#/definitions/model.PeerMailRequest'
- description: The configuration style
in: query
name: style
type: string
produces: produces:
- application/json - application/json
responses: responses:
@ -1093,6 +1101,10 @@ paths:
name: id name: id
required: true required: true
type: string type: string
- description: The configuration style
in: query
name: style
type: string
produces: produces:
- image/png - image/png
- application/json - application/json
@ -1121,6 +1133,10 @@ paths:
name: id name: id
required: true required: true
type: string type: string
- description: The configuration style
in: query
name: style
type: string
produces: produces:
- application/json - application/json
responses: responses:

View File

@ -100,6 +100,7 @@ func (s *Server) Run(ctx context.Context, listenAddress string) {
srvContext, cancelFn := context.WithCancel(ctx) srvContext, cancelFn := context.WithCancel(ctx)
go func() { go func() {
var err error var err error
slog.Debug("starting server", "certFile", s.cfg.Web.CertFile, "keyFile", s.cfg.Web.KeyFile)
if s.cfg.Web.CertFile != "" && s.cfg.Web.KeyFile != "" { if s.cfg.Web.CertFile != "" && s.cfg.Web.KeyFile != "" {
err = srv.ListenAndServeTLS(s.cfg.Web.CertFile, s.cfg.Web.KeyFile) err = srv.ListenAndServeTLS(s.cfg.Web.CertFile, s.cfg.Web.KeyFile)
} else { } else {

View File

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

View File

@ -2,6 +2,7 @@ package handlers
import ( import (
"context" "context"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@ -189,7 +190,7 @@ func (e AuthEndpoint) handleSessionInfoGet() http.HandlerFunc {
// @Summary Initiate the OAuth login flow. // @Summary Initiate the OAuth login flow.
// @Produce json // @Produce json
// @Success 200 {object} []model.LoginProviderInfo // @Success 200 {object} []model.LoginProviderInfo
// @Router /auth/{provider}/init [get] // @Router /auth/login/{provider}/init [get]
func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc { func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
currentSession := e.session.GetData(r.Context()) currentSession := e.session.GetData(r.Context())
@ -234,6 +235,8 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
authCodeUrl, state, nonce, err := e.authService.OauthLoginStep1(context.Background(), provider) authCodeUrl, state, nonce, err := e.authService.OauthLoginStep1(context.Background(), provider)
if err != nil { if err != nil {
slog.Debug("failed to create oauth auth code URL",
"provider", provider, "error", err)
if autoRedirect && e.isValidReturnUrl(returnTo) { if autoRedirect && e.isValidReturnUrl(returnTo) {
redirectToReturn() redirectToReturn()
} else { } else {
@ -268,7 +271,7 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
// @Summary Handle the OAuth callback. // @Summary Handle the OAuth callback.
// @Produce json // @Produce json
// @Success 200 {object} []model.LoginProviderInfo // @Success 200 {object} []model.LoginProviderInfo
// @Router /auth/{provider}/callback [get] // @Router /auth/login/{provider}/callback [get]
func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc { func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
currentSession := e.session.GetData(r.Context()) currentSession := e.session.GetData(r.Context())
@ -306,6 +309,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
oauthState := request.Query(r, "state") oauthState := request.Query(r, "state")
if provider != currentSession.OauthProvider { if provider != currentSession.OauthProvider {
slog.Debug("invalid oauth provider in callback",
"expected", currentSession.OauthProvider, "got", provider, "state", oauthState)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
redirectToReturn() redirectToReturn()
} else { } else {
@ -315,6 +320,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
return return
} }
if oauthState != currentSession.OauthState { if oauthState != currentSession.OauthState {
slog.Debug("invalid oauth state in callback",
"expected", currentSession.OauthState, "got", oauthState, "provider", provider)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
redirectToReturn() redirectToReturn()
} else { } else {
@ -324,11 +331,13 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
return return
} }
loginCtx, cancel := context.WithTimeout(context.Background(), 1000*time.Second) loginCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // avoid long waits
user, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce, user, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce,
oauthCode) oauthCode)
cancel() cancel()
if err != nil { if err != nil {
slog.Debug("failed to process oauth code",
"provider", provider, "state", oauthState, "error", err)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
redirectToReturn() redirectToReturn()
} else { } else {

View File

@ -124,11 +124,14 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
} }
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 // For anonymous users, we return the settings object with minimal information
if sessionUser.Id == domain.CtxUnknownUserId || sessionUser.Id == "" { if sessionUser.Id == domain.CtxUnknownUserId || sessionUser.Id == "" {
respond.JSON(w, http.StatusOK, model.Settings{ respond.JSON(w, http.StatusOK, model.Settings{
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
AvailableBackends: []model.SettingsBackendNames{}, // return an empty list instead of null AvailableBackends: []model.SettingsBackendNames{}, // return an empty list instead of null
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
}) })
} else { } else {
respond.JSON(w, http.StatusOK, model.Settings{ respond.JSON(w, http.StatusOK, model.Settings{
@ -139,6 +142,7 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
MinPasswordLength: e.cfg.Auth.MinPasswordLength, MinPasswordLength: e.cfg.Auth.MinPasswordLength,
AvailableBackends: controllerFn(), 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 deletes the peer with the given id.
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
// GetPeerConfig returns the peer configuration for the given id. // GetPeerConfig returns the peer configuration for the given id.
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
// GetPeerConfigQrCode returns the peer configuration as qr code for the given id. // GetPeerConfigQrCode returns the peer configuration as qr code for the given id.
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
// SendPeerEmail sends the peer configuration via email. // SendPeerEmail sends the peer configuration via email.
SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error
// GetPeerStats returns the peer stats for the given interface. // GetPeerStats returns the peer stats for the given interface.
GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error)
} }
@ -355,6 +355,7 @@ func (e PeerEndpoint) handleDelete() http.HandlerFunc {
// @Summary Get peer configuration as string. // @Summary Get peer configuration as string.
// @Produce json // @Produce json
// @Param id path string true "The peer identifier" // @Param id path string true "The peer identifier"
// @Param style query string false "The configuration style"
// @Success 200 {object} string // @Success 200 {object} string
// @Failure 400 {object} model.Error // @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error // @Failure 500 {object} model.Error
@ -369,7 +370,9 @@ func (e PeerEndpoint) handleConfigGet() http.HandlerFunc {
return return
} }
configTxt, err := e.peerService.GetPeerConfig(r.Context(), domain.PeerIdentifier(id)) configStyle := e.getConfigStyle(r)
configTxt, err := e.peerService.GetPeerConfig(r.Context(), domain.PeerIdentifier(id), configStyle)
if err != nil { if err != nil {
respond.JSON(w, http.StatusInternalServerError, model.Error{ respond.JSON(w, http.StatusInternalServerError, model.Error{
Code: http.StatusInternalServerError, Message: err.Error(), Code: http.StatusInternalServerError, Message: err.Error(),
@ -397,6 +400,7 @@ func (e PeerEndpoint) handleConfigGet() http.HandlerFunc {
// @Produce png // @Produce png
// @Produce json // @Produce json
// @Param id path string true "The peer identifier" // @Param id path string true "The peer identifier"
// @Param style query string false "The configuration style"
// @Success 200 {file} binary // @Success 200 {file} binary
// @Failure 400 {object} model.Error // @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error // @Failure 500 {object} model.Error
@ -411,7 +415,9 @@ func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc {
return return
} }
configQr, err := e.peerService.GetPeerConfigQrCode(r.Context(), domain.PeerIdentifier(id)) configStyle := e.getConfigStyle(r)
configQr, err := e.peerService.GetPeerConfigQrCode(r.Context(), domain.PeerIdentifier(id), configStyle)
if err != nil { if err != nil {
respond.JSON(w, http.StatusInternalServerError, model.Error{ respond.JSON(w, http.StatusInternalServerError, model.Error{
Code: http.StatusInternalServerError, Message: err.Error(), Code: http.StatusInternalServerError, Message: err.Error(),
@ -438,6 +444,7 @@ func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc {
// @Summary Send peer configuration via email. // @Summary Send peer configuration via email.
// @Produce json // @Produce json
// @Param request body model.PeerMailRequest true "The peer mail request data" // @Param request body model.PeerMailRequest true "The peer mail request data"
// @Param style query string false "The configuration style"
// @Success 204 "No content if mail sending was successful" // @Success 204 "No content if mail sending was successful"
// @Failure 400 {object} model.Error // @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error // @Failure 500 {object} model.Error
@ -460,11 +467,13 @@ func (e PeerEndpoint) handleEmailPost() http.HandlerFunc {
return return
} }
configStyle := e.getConfigStyle(r)
peerIds := make([]domain.PeerIdentifier, len(req.Identifiers)) peerIds := make([]domain.PeerIdentifier, len(req.Identifiers))
for i := range req.Identifiers { for i := range req.Identifiers {
peerIds[i] = domain.PeerIdentifier(req.Identifiers[i]) peerIds[i] = domain.PeerIdentifier(req.Identifiers[i])
} }
if err := e.peerService.SendPeerEmail(r.Context(), req.LinkOnly, peerIds...); err != nil { if err := e.peerService.SendPeerEmail(r.Context(), req.LinkOnly, configStyle, peerIds...); err != nil {
respond.JSON(w, http.StatusInternalServerError, respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return return
@ -504,3 +513,11 @@ func (e PeerEndpoint) handleStatsGet() http.HandlerFunc {
respond.JSON(w, http.StatusOK, model.NewPeerStats(e.cfg.Statistics.CollectPeerData, stats)) respond.JSON(w, http.StatusOK, model.NewPeerStats(e.cfg.Statistics.CollectPeerData, stats))
} }
} }
func (e PeerEndpoint) getConfigStyle(r *http.Request) string {
configStyle := request.QueryDefault(r, "style", domain.ConfigStyleWgQuick)
if configStyle != domain.ConfigStyleWgQuick && configStyle != domain.ConfigStyleRaw {
configStyle = domain.ConfigStyleWgQuick // default to wg-quick style
}
return configStyle
}

View File

@ -13,6 +13,7 @@ type Settings struct {
WebAuthnEnabled bool `json:"WebAuthnEnabled"` WebAuthnEnabled bool `json:"WebAuthnEnabled"`
MinPasswordLength int `json:"MinPasswordLength"` MinPasswordLength int `json:"MinPasswordLength"`
AvailableBackends []SettingsBackendNames `json:"AvailableBackends"` AvailableBackends []SettingsBackendNames `json:"AvailableBackends"`
LoginFormVisible bool `json:"LoginFormVisible"`
} }
type SettingsBackendNames struct { type SettingsBackendNames struct {

View File

@ -198,7 +198,7 @@ func NewPeerStats(enabled bool, src []domain.PeerStatus) *PeerStats {
for _, srcStat := range src { for _, srcStat := range src {
stats[string(srcStat.PeerId)] = PeerStatData{ stats[string(srcStat.PeerId)] = PeerStatData{
IsConnected: srcStat.IsConnected(), IsConnected: srcStat.IsConnected,
IsPingable: srcStat.IsPingable, IsPingable: srcStat.IsPingable,
LastPing: srcStat.LastPing, LastPing: srcStat.LastPing,
BytesReceived: srcStat.BytesReceived, BytesReceived: srcStat.BytesReceived,

View File

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

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

View File

@ -46,7 +46,7 @@ type TemplateRenderer interface {
// GetInterfaceConfig returns the configuration file for the given interface. // GetInterfaceConfig returns the configuration file for the given interface.
GetInterfaceConfig(iface *domain.Interface, peers []domain.Peer) (io.Reader, error) GetInterfaceConfig(iface *domain.Interface, peers []domain.Peer) (io.Reader, error)
// GetPeerConfig returns the configuration file for the given peer. // GetPeerConfig returns the configuration file for the given peer.
GetPeerConfig(peer *domain.Peer) (io.Reader, error) GetPeerConfig(peer *domain.Peer, style string) (io.Reader, error)
} }
type EventBus interface { type EventBus interface {
@ -186,7 +186,7 @@ func (m Manager) GetInterfaceConfig(ctx context.Context, id domain.InterfaceIden
// GetPeerConfig returns the configuration file for the given peer. // GetPeerConfig returns the configuration file for the given peer.
// The file is structured in wg-quick format. // The file is structured in wg-quick format.
func (m Manager) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) { func (m Manager) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) {
peer, err := m.wg.GetPeer(ctx, id) peer, err := m.wg.GetPeer(ctx, id)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch peer %s: %w", id, err) return nil, fmt.Errorf("failed to fetch peer %s: %w", id, err)
@ -196,11 +196,11 @@ func (m Manager) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (i
return nil, err return nil, err
} }
return m.tplHandler.GetPeerConfig(peer) return m.tplHandler.GetPeerConfig(peer, style)
} }
// GetPeerConfigQrCode returns a QR code image containing the configuration for the given peer. // GetPeerConfigQrCode returns a QR code image containing the configuration for the given peer.
func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) { func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error) {
peer, err := m.wg.GetPeer(ctx, id) peer, err := m.wg.GetPeer(ctx, id)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch peer %s: %w", id, err) return nil, fmt.Errorf("failed to fetch peer %s: %w", id, err)
@ -210,7 +210,7 @@ func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifi
return nil, err return nil, err
} }
cfgData, err := m.tplHandler.GetPeerConfig(peer) cfgData, err := m.tplHandler.GetPeerConfig(peer, style)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get peer config for %s: %w", id, err) return nil, fmt.Errorf("failed to get peer config for %s: %w", id, err)
} }

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. // GetPeerConfig returns the rendered configuration file for a WireGuard peer.
func (c TemplateHandler) GetPeerConfig(peer *domain.Peer) (io.Reader, error) { func (c TemplateHandler) GetPeerConfig(peer *domain.Peer, style string) (io.Reader, error) {
var tplBuff bytes.Buffer var tplBuff bytes.Buffer
err := c.templates.ExecuteTemplate(&tplBuff, "wg_peer.tpl", map[string]any{ err := c.templates.ExecuteTemplate(&tplBuff, "wg_peer.tpl", map[string]any{
"Peer": peer, "Style": style,
"Peer": peer,
"Portal": map[string]any{ "Portal": map[string]any{
"Version": "unknown", "Version": "unknown",
}, },

View File

@ -1,6 +1,8 @@
# AUTOGENERATED FILE - DO NOT EDIT # AUTOGENERATED FILE - DO NOT EDIT
# This file uses wg-quick format. # This file uses {{ .Style }} format.
{{- if eq .Style "wgquick"}}
# See https://man7.org/linux/man-pages/man8/wg-quick.8.html#CONFIGURATION # See https://man7.org/linux/man-pages/man8/wg-quick.8.html#CONFIGURATION
{{- end}}
# Lines starting with the -WGP- tag are used by # Lines starting with the -WGP- tag are used by
# the WireGuard Portal configuration parser. # the WireGuard Portal configuration parser.
@ -21,22 +23,27 @@
# Core settings # Core settings
PrivateKey = {{ .Peer.Interface.KeyPair.PrivateKey }} PrivateKey = {{ .Peer.Interface.KeyPair.PrivateKey }}
{{- if eq .Style "wgquick"}}
Address = {{ CidrsToString .Peer.Interface.Addresses }} Address = {{ CidrsToString .Peer.Interface.Addresses }}
{{- end}}
# Misc. settings (optional) # Misc. settings (optional)
{{- if eq .Style "wgquick"}}
{{- if .Peer.Interface.DnsStr.GetValue}} {{- if .Peer.Interface.DnsStr.GetValue}}
DNS = {{ .Peer.Interface.DnsStr.GetValue }} {{- if .Peer.Interface.DnsSearchStr.GetValue}}, {{ .Peer.Interface.DnsSearchStr.GetValue }} {{- end}} DNS = {{ .Peer.Interface.DnsStr.GetValue }} {{- if .Peer.Interface.DnsSearchStr.GetValue}}, {{ .Peer.Interface.DnsSearchStr.GetValue }} {{- end}}
{{- end}} {{- end}}
{{- if ne .Peer.Interface.Mtu.GetValue 0}} {{- if ne .Peer.Interface.Mtu.GetValue 0}}
MTU = {{ .Peer.Interface.Mtu.GetValue }} MTU = {{ .Peer.Interface.Mtu.GetValue }}
{{- end}} {{- end}}
{{- if ne .Peer.Interface.FirewallMark.GetValue 0}}
FwMark = {{ .Peer.Interface.FirewallMark.GetValue }}
{{- end}}
{{- if ne .Peer.Interface.RoutingTable.GetValue ""}} {{- if ne .Peer.Interface.RoutingTable.GetValue ""}}
Table = {{ .Peer.Interface.RoutingTable.GetValue }} Table = {{ .Peer.Interface.RoutingTable.GetValue }}
{{- end}} {{- end}}
{{- end}}
{{- if ne .Peer.Interface.FirewallMark.GetValue 0}}
FwMark = {{ .Peer.Interface.FirewallMark.GetValue }}
{{- end}}
{{- if eq .Style "wgquick"}}
# Interface hooks (optional) # Interface hooks (optional)
{{- if .Peer.Interface.PreUp.GetValue}} {{- if .Peer.Interface.PreUp.GetValue}}
PreUp = {{ .Peer.Interface.PreUp.GetValue }} PreUp = {{ .Peer.Interface.PreUp.GetValue }}
@ -50,6 +57,7 @@ PreDown = {{ .Peer.Interface.PreDown.GetValue }}
{{- if .Peer.Interface.PostDown.GetValue}} {{- if .Peer.Interface.PostDown.GetValue}}
PostDown = {{ .Peer.Interface.PostDown.GetValue }} PostDown = {{ .Peer.Interface.PostDown.GetValue }}
{{- end}} {{- end}}
{{- end}}
[Peer] [Peer]
PublicKey = {{ .Peer.EndpointPublicKey.GetValue }} PublicKey = {{ .Peer.EndpointPublicKey.GetValue }}

View File

@ -36,6 +36,7 @@ const TopicPeerDeleted = "peer:deleted"
const TopicPeerUpdated = "peer:updated" const TopicPeerUpdated = "peer:updated"
const TopicPeerInterfaceUpdated = "peer:interface:updated" const TopicPeerInterfaceUpdated = "peer:interface:updated"
const TopicPeerIdentifierUpdated = "peer:identifier:updated" const TopicPeerIdentifierUpdated = "peer:identifier:updated"
const TopicPeerStateChanged = "peer:state:changed"
// endregion peer-events // endregion peer-events

View File

@ -21,9 +21,9 @@ type ConfigFileManager interface {
// GetInterfaceConfig returns the configuration for the given interface. // GetInterfaceConfig returns the configuration for the given interface.
GetInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) (io.Reader, error) GetInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) (io.Reader, error)
// GetPeerConfig returns the configuration for the given peer. // GetPeerConfig returns the configuration for the given peer.
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) GetPeerConfig(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
// GetPeerConfigQrCode returns the QR code for the given peer. // GetPeerConfigQrCode returns the QR code for the given peer.
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier, style string) (io.Reader, error)
} }
type UserDatabaseRepo interface { type UserDatabaseRepo interface {
@ -71,7 +71,7 @@ func NewMailManager(
users UserDatabaseRepo, users UserDatabaseRepo,
wg WireguardDatabaseRepo, wg WireguardDatabaseRepo,
) (*Manager, error) { ) (*Manager, error) {
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl) tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to initialize template handler: %w", err) return nil, fmt.Errorf("failed to initialize template handler: %w", err)
} }
@ -89,7 +89,7 @@ func NewMailManager(
} }
// SendPeerEmail sends an email to the user linked to the given peers. // SendPeerEmail sends an email to the user linked to the given peers.
func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...domain.PeerIdentifier) error { func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error {
for _, peerId := range peers { for _, peerId := range peers {
peer, err := m.wg.GetPeer(ctx, peerId) peer, err := m.wg.GetPeer(ctx, peerId)
if err != nil { if err != nil {
@ -123,7 +123,7 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...doma
continue continue
} }
err = m.sendPeerEmail(ctx, linkOnly, user, peer) err = m.sendPeerEmail(ctx, linkOnly, style, user, peer)
if err != nil { if err != nil {
return fmt.Errorf("failed to send peer email for %s: %w", peerId, err) return fmt.Errorf("failed to send peer email for %s: %w", peerId, err)
} }
@ -132,7 +132,13 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, peers ...doma
return nil return nil
} }
func (m Manager) sendPeerEmail(ctx context.Context, linkOnly bool, user *domain.User, peer *domain.Peer) error { func (m Manager) sendPeerEmail(
ctx context.Context,
linkOnly bool,
style string,
user *domain.User,
peer *domain.Peer,
) error {
qrName := "WireGuardQRCode.png" qrName := "WireGuardQRCode.png"
configName := peer.GetConfigFileName() configName := peer.GetConfigFileName()
@ -148,12 +154,12 @@ func (m Manager) sendPeerEmail(ctx context.Context, linkOnly bool, user *domain.
} }
} else { } else {
peerConfig, err := m.configFiles.GetPeerConfig(ctx, peer.Identifier) peerConfig, err := m.configFiles.GetPeerConfig(ctx, peer.Identifier, style)
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch peer config for %s: %w", peer.Identifier, err) return fmt.Errorf("failed to fetch peer config for %s: %w", peer.Identifier, err)
} }
peerConfigQr, err := m.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier) peerConfigQr, err := m.configFiles.GetPeerConfigQrCode(ctx, peer.Identifier, style)
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch peer config QR code for %s: %w", peer.Identifier, err) return fmt.Errorf("failed to fetch peer config QR code for %s: %w", peer.Identifier, err)
} }

View File

@ -17,11 +17,12 @@ var TemplateFiles embed.FS
// TemplateHandler is a struct that holds the html and text templates. // TemplateHandler is a struct that holds the html and text templates.
type TemplateHandler struct { type TemplateHandler struct {
portalUrl string portalUrl string
portalName string
htmlTemplates *htmlTemplate.Template htmlTemplates *htmlTemplate.Template
textTemplates *template.Template textTemplates *template.Template
} }
func newTemplateHandler(portalUrl string) (*TemplateHandler, error) { func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error) {
htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml") htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse html template files: %w", err) return nil, fmt.Errorf("failed to parse html template files: %w", err)
@ -34,6 +35,7 @@ func newTemplateHandler(portalUrl string) (*TemplateHandler, error) {
handler := &TemplateHandler{ handler := &TemplateHandler{
portalUrl: portalUrl, portalUrl: portalUrl,
portalName: portalName,
htmlTemplates: htmlTemplateCache, htmlTemplates: htmlTemplateCache,
textTemplates: txtTemplateCache, textTemplates: txtTemplateCache,
} }
@ -81,6 +83,7 @@ func (c TemplateHandler) GetConfigMailWithAttachment(user *domain.User, cfgName,
"ConfigFileName": cfgName, "ConfigFileName": cfgName,
"QrcodePngName": qrName, "QrcodePngName": qrName,
"PortalUrl": c.portalUrl, "PortalUrl": c.portalUrl,
"PortalName": c.portalName,
}) })
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gotpl: %w", err) return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gotpl: %w", err)
@ -91,6 +94,7 @@ func (c TemplateHandler) GetConfigMailWithAttachment(user *domain.User, cfgName,
"ConfigFileName": cfgName, "ConfigFileName": cfgName,
"QrcodePngName": qrName, "QrcodePngName": qrName,
"PortalUrl": c.portalUrl, "PortalUrl": c.portalUrl,
"PortalName": c.portalName,
}) })
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gohtml: %w", err) return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gohtml: %w", err)

View File

@ -19,7 +19,7 @@
<!--[if !mso]><!--> <!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" />
<!--<![endif]--> <!--<![endif]-->
<title>Email Template</title> <title>{{$.PortalName}}</title>
<!--[if gte mso 9]> <!--[if gte mso 9]>
<style type="text/css" media="all"> <style type="text/css" media="all">
sup { font-size: 100% !important; } sup { font-size: 100% !important; }
@ -143,7 +143,7 @@
<td align="left"> <td align="left">
<table border="0" cellspacing="0" cellpadding="0"> <table border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
<td class="blue-button text-button" style="background:#000000; color:#c1cddc; font-family:'Muli', Arial,sans-serif; font-size:14px; line-height:18px; padding:12px 30px; text-align:center; border-radius:0px 22px 22px 22px; font-weight:bold;"><a href="https://www.wireguard.com/install" target="_blank" class="link-white" style="color:#ffffff; text-decoration:none;"><span class="link-white" style="color:#ffffff; text-decoration:none;">Download WireGuard VPN Client</span></a></td> <td class="blue-button text-button" style="background:#000000; color:#ffffff; font-family:'Muli', Arial,sans-serif; font-size:14px; line-height:18px; padding:12px 30px; text-align:center; border-radius:0px 22px 22px 22px; font-weight:bold;"><a href="https://www.wireguard.com/install" target="_blank" class="link-white" style="color:#ffffff; text-decoration:none;"><span class="link-white" style="color:#ffffff; text-decoration:none;">Download WireGuard VPN Client</span></a></td>
</tr> </tr>
</table> </table>
</td> </td>
@ -167,10 +167,10 @@
<td class="p30-15 bbrr" style="padding: 50px 30px; border-radius:0px 0px 26px 26px;" bgcolor="#ffffff"> <td class="p30-15 bbrr" style="padding: 50px 30px; border-radius:0px 0px 26px 26px;" bgcolor="#ffffff">
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
<td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated using WireGuard Portal.</td> <td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated by {{$.PortalName}}.</td>
</tr> </tr>
<tr> <tr>
<td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{$.PortalUrl}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit WireGuard Portal</span></a></td> <td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{$.PortalUrl}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit {{$.PortalName}}</span></a></td>
</tr> </tr>
</table> </table>
</td> </td>

View File

@ -20,5 +20,5 @@ You can download and install the WireGuard VPN client from:
https://www.wireguard.com/install/ https://www.wireguard.com/install/
This mail was generated using WireGuard Portal. This mail was generated by {{$.PortalName}}.
{{$.PortalUrl}} {{$.PortalUrl}}

View File

@ -19,7 +19,7 @@
<!--[if !mso]><!--> <!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" />
<!--<![endif]--> <!--<![endif]-->
<title>Email Template</title> <title>{{$.PortalName}}</title>
<!--[if gte mso 9]> <!--[if gte mso 9]>
<style type="text/css" media="all"> <style type="text/css" media="all">
sup { font-size: 100% !important; } sup { font-size: 100% !important; }
@ -143,7 +143,7 @@
<td align="left"> <td align="left">
<table border="0" cellspacing="0" cellpadding="0"> <table border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
<td class="blue-button text-button" style="background:#000000; color:#c1cddc; font-family:'Muli', Arial,sans-serif; font-size:14px; line-height:18px; padding:12px 30px; text-align:center; border-radius:0px 22px 22px 22px; font-weight:bold;"><a href="https://www.wireguard.com/install" target="_blank" class="link-white" style="color:#ffffff; text-decoration:none;"><span class="link-white" style="color:#ffffff; text-decoration:none;">Download WireGuard VPN Client</span></a></td> <td class="blue-button text-button" style="background:#000000; color:#ffffff; font-family:'Muli', Arial,sans-serif; font-size:14px; line-height:18px; padding:12px 30px; text-align:center; border-radius:0px 22px 22px 22px; font-weight:bold;"><a href="https://www.wireguard.com/install" target="_blank" class="link-white" style="color:#ffffff; text-decoration:none;"><span class="link-white" style="color:#ffffff; text-decoration:none;">Download WireGuard VPN Client</span></a></td>
</tr> </tr>
</table> </table>
</td> </td>
@ -167,10 +167,10 @@
<td class="p30-15 bbrr" style="padding: 50px 30px; border-radius:0px 0px 26px 26px;" bgcolor="#ffffff"> <td class="p30-15 bbrr" style="padding: 50px 30px; border-radius:0px 0px 26px 26px;" bgcolor="#ffffff">
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
<td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated using WireGuard Portal.</td> <td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated by {{$.PortalName}}.</td>
</tr> </tr>
<tr> <tr>
<td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{$.PortalUrl}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit WireGuard Portal</span></a></td> <td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{$.PortalUrl}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit {{$.PortalName}}</span></a></td>
</tr> </tr>
</table> </table>
</td> </td>

View File

@ -20,5 +20,5 @@ You can download and install the WireGuard VPN client from:
https://www.wireguard.com/install/ https://www.wireguard.com/install/
This mail was generated using WireGuard Portal. This mail was generated by {{$.PortalName}}.
{{$.PortalUrl}} {{$.PortalUrl}}

View File

@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"github.com/h44z/wg-portal/internal/app" "github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/webhooks/models"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
@ -64,6 +65,7 @@ func (m Manager) connectToMessageBus() {
_ = m.bus.Subscribe(app.TopicPeerCreated, m.handlePeerCreateEvent) _ = m.bus.Subscribe(app.TopicPeerCreated, m.handlePeerCreateEvent)
_ = m.bus.Subscribe(app.TopicPeerUpdated, m.handlePeerUpdateEvent) _ = m.bus.Subscribe(app.TopicPeerUpdated, m.handlePeerUpdateEvent)
_ = m.bus.Subscribe(app.TopicPeerDeleted, m.handlePeerDeleteEvent) _ = m.bus.Subscribe(app.TopicPeerDeleted, m.handlePeerDeleteEvent)
_ = m.bus.Subscribe(app.TopicPeerStateChanged, m.handlePeerStateChangeEvent)
_ = m.bus.Subscribe(app.TopicInterfaceCreated, m.handleInterfaceCreateEvent) _ = m.bus.Subscribe(app.TopicInterfaceCreated, m.handleInterfaceCreateEvent)
_ = m.bus.Subscribe(app.TopicInterfaceUpdated, m.handleInterfaceUpdateEvent) _ = m.bus.Subscribe(app.TopicInterfaceUpdated, m.handleInterfaceUpdateEvent)
@ -100,39 +102,47 @@ func (m Manager) sendWebhook(ctx context.Context, data io.Reader) error {
} }
func (m Manager) handleUserCreateEvent(user domain.User) { func (m Manager) handleUserCreateEvent(user domain.User) {
m.handleGenericEvent(WebhookEventCreate, user) m.handleGenericEvent(WebhookEventCreate, models.NewUser(user))
} }
func (m Manager) handleUserUpdateEvent(user domain.User) { func (m Manager) handleUserUpdateEvent(user domain.User) {
m.handleGenericEvent(WebhookEventUpdate, user) m.handleGenericEvent(WebhookEventUpdate, models.NewUser(user))
} }
func (m Manager) handleUserDeleteEvent(user domain.User) { func (m Manager) handleUserDeleteEvent(user domain.User) {
m.handleGenericEvent(WebhookEventDelete, user) m.handleGenericEvent(WebhookEventDelete, models.NewUser(user))
} }
func (m Manager) handlePeerCreateEvent(peer domain.Peer) { func (m Manager) handlePeerCreateEvent(peer domain.Peer) {
m.handleGenericEvent(WebhookEventCreate, peer) m.handleGenericEvent(WebhookEventCreate, models.NewPeer(peer))
} }
func (m Manager) handlePeerUpdateEvent(peer domain.Peer) { func (m Manager) handlePeerUpdateEvent(peer domain.Peer) {
m.handleGenericEvent(WebhookEventUpdate, peer) m.handleGenericEvent(WebhookEventUpdate, models.NewPeer(peer))
} }
func (m Manager) handlePeerDeleteEvent(peer domain.Peer) { func (m Manager) handlePeerDeleteEvent(peer domain.Peer) {
m.handleGenericEvent(WebhookEventDelete, peer) m.handleGenericEvent(WebhookEventDelete, models.NewPeer(peer))
} }
func (m Manager) handleInterfaceCreateEvent(iface domain.Interface) { func (m Manager) handleInterfaceCreateEvent(iface domain.Interface) {
m.handleGenericEvent(WebhookEventCreate, iface) m.handleGenericEvent(WebhookEventCreate, models.NewInterface(iface))
} }
func (m Manager) handleInterfaceUpdateEvent(iface domain.Interface) { func (m Manager) handleInterfaceUpdateEvent(iface domain.Interface) {
m.handleGenericEvent(WebhookEventUpdate, iface) m.handleGenericEvent(WebhookEventUpdate, models.NewInterface(iface))
} }
func (m Manager) handleInterfaceDeleteEvent(iface domain.Interface) { func (m Manager) handleInterfaceDeleteEvent(iface domain.Interface) {
m.handleGenericEvent(WebhookEventDelete, iface) m.handleGenericEvent(WebhookEventDelete, models.NewInterface(iface))
}
func (m Manager) handlePeerStateChangeEvent(peerStatus domain.PeerStatus, peer domain.Peer) {
if peerStatus.IsConnected {
m.handleGenericEvent(WebhookEventConnect, models.NewPeerMetrics(peerStatus, peer))
} else {
m.handleGenericEvent(WebhookEventDisconnect, models.NewPeerMetrics(peerStatus, peer))
}
} }
func (m Manager) handleGenericEvent(action WebhookEvent, payload any) { func (m Manager) handleGenericEvent(action WebhookEvent, payload any) {
@ -168,15 +178,18 @@ func (m Manager) createWebhookData(action WebhookEvent, payload any) (*WebhookDa
} }
switch v := payload.(type) { switch v := payload.(type) {
case domain.User: case models.User:
d.Entity = WebhookEntityUser d.Entity = WebhookEntityUser
d.Identifier = string(v.Identifier) d.Identifier = v.Identifier
case domain.Peer: case models.Peer:
d.Entity = WebhookEntityPeer d.Entity = WebhookEntityPeer
d.Identifier = string(v.Identifier) d.Identifier = v.Identifier
case domain.Interface: case models.Interface:
d.Entity = WebhookEntityInterface d.Entity = WebhookEntityInterface
d.Identifier = string(v.Identifier) d.Identifier = v.Identifier
case models.PeerMetrics:
d.Entity = WebhookEntityPeerMetric
d.Identifier = v.Peer.Identifier
default: default:
return nil, fmt.Errorf("unsupported payload type: %T", v) return nil, fmt.Errorf("unsupported payload type: %T", v)
} }

View File

@ -34,15 +34,18 @@ func (d *WebhookData) Serialize() (io.Reader, error) {
type WebhookEntity = string type WebhookEntity = string
const ( const (
WebhookEntityUser WebhookEntity = "user" WebhookEntityUser WebhookEntity = "user"
WebhookEntityPeer WebhookEntity = "peer" WebhookEntityPeer WebhookEntity = "peer"
WebhookEntityInterface WebhookEntity = "interface" WebhookEntityPeerMetric WebhookEntity = "peer_metric"
WebhookEntityInterface WebhookEntity = "interface"
) )
type WebhookEvent = string type WebhookEvent = string
const ( const (
WebhookEventCreate WebhookEvent = "create" WebhookEventCreate WebhookEvent = "create"
WebhookEventUpdate WebhookEvent = "update" WebhookEventUpdate WebhookEvent = "update"
WebhookEventDelete WebhookEvent = "delete" WebhookEventDelete WebhookEvent = "delete"
WebhookEventConnect WebhookEvent = "connect"
WebhookEventDisconnect WebhookEvent = "disconnect"
) )

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

@ -36,6 +36,8 @@ type StatisticsMetricsServer interface {
type StatisticsEventBus interface { type StatisticsEventBus interface {
// Subscribe subscribes to a topic // Subscribe subscribes to a topic
Subscribe(topic string, fn interface{}) error Subscribe(topic string, fn interface{}) error
// Publish sends a message to the message bus.
Publish(topic string, args ...any)
} }
type pingJob struct { type pingJob struct {
@ -53,6 +55,8 @@ type StatisticsCollector struct {
db StatisticsDatabaseRepo db StatisticsDatabaseRepo
wg *ControllerManager wg *ControllerManager
ms StatisticsMetricsServer ms StatisticsMetricsServer
peerChangeEvent chan domain.PeerIdentifier
} }
// NewStatisticsCollector creates a new statistics collector. // NewStatisticsCollector creates a new statistics collector.
@ -169,8 +173,12 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
continue continue
} }
for _, peer := range peers { for _, peer := range peers {
var connectionStateChanged bool
var newPeerStatus domain.PeerStatus
err = c.db.UpdatePeerStatus(ctx, peer.Identifier, err = c.db.UpdatePeerStatus(ctx, peer.Identifier,
func(p *domain.PeerStatus) (*domain.PeerStatus, error) { func(p *domain.PeerStatus) (*domain.PeerStatus, error) {
wasConnected := p.IsConnected
var lastHandshake *time.Time var lastHandshake *time.Time
if !peer.LastHandshake.IsZero() { if !peer.LastHandshake.IsZero() {
lastHandshake = &peer.LastHandshake lastHandshake = &peer.LastHandshake
@ -184,6 +192,13 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
p.BytesTransmitted = peer.BytesDownload // store bytes that where received from the peer and sent by the server p.BytesTransmitted = peer.BytesDownload // store bytes that where received from the peer and sent by the server
p.Endpoint = peer.Endpoint p.Endpoint = peer.Endpoint
p.LastHandshake = lastHandshake p.LastHandshake = lastHandshake
p.CalcConnected()
if wasConnected != p.IsConnected {
slog.Debug("peer connection state changed", "peer", peer.Identifier, "connected", p.IsConnected)
connectionStateChanged = true
newPeerStatus = *p // store new status for event publishing
}
// Update prometheus metrics // Update prometheus metrics
go c.updatePeerMetrics(ctx, *p) go c.updatePeerMetrics(ctx, *p)
@ -195,6 +210,17 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
} else { } else {
slog.Debug("updated peer status", "peer", peer.Identifier) slog.Debug("updated peer status", "peer", peer.Identifier)
} }
if connectionStateChanged {
peerModel, err := c.db.GetPeer(ctx, peer.Identifier)
if err != nil {
slog.Error("failed to fetch peer for data collection", "peer", peer.Identifier, "error",
err)
continue
}
// publish event if connection state changed
c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus, *peerModel)
}
} }
} }
} }
@ -301,12 +327,18 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) {
for job := range c.pingJobs { for job := range c.pingJobs {
peer := job.Peer peer := job.Peer
backend := job.Backend backend := job.Backend
var connectionStateChanged bool
var newPeerStatus domain.PeerStatus
peerPingable := c.isPeerPingable(ctx, backend, peer) peerPingable := c.isPeerPingable(ctx, backend, peer)
slog.Debug("peer ping check completed", "peer", peer.Identifier, "pingable", peerPingable) slog.Debug("peer ping check completed", "peer", peer.Identifier, "pingable", peerPingable)
now := time.Now() now := time.Now()
err := c.db.UpdatePeerStatus(ctx, peer.Identifier, err := c.db.UpdatePeerStatus(ctx, peer.Identifier,
func(p *domain.PeerStatus) (*domain.PeerStatus, error) { func(p *domain.PeerStatus) (*domain.PeerStatus, error) {
wasConnected := p.IsConnected
if peerPingable { if peerPingable {
p.IsPingable = true p.IsPingable = true
p.LastPing = &now p.LastPing = &now
@ -314,6 +346,13 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) {
p.IsPingable = false p.IsPingable = false
p.LastPing = nil p.LastPing = nil
} }
p.UpdatedAt = time.Now()
p.CalcConnected()
if wasConnected != p.IsConnected {
connectionStateChanged = true
newPeerStatus = *p // store new status for event publishing
}
// Update prometheus metrics // Update prometheus metrics
go c.updatePeerMetrics(ctx, *p) go c.updatePeerMetrics(ctx, *p)
@ -325,6 +364,11 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) {
} else { } else {
slog.Debug("updated peer ping status", "peer", peer.Identifier) slog.Debug("updated peer ping status", "peer", peer.Identifier)
} }
if connectionStateChanged {
// publish event if connection state changed
c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus, peer)
}
} }
} }

View File

@ -471,7 +471,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
physicalInterface, _ := m.wg.GetController(*existingInterface).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) return fmt.Errorf("pre-delete hooks failed: %w", err)
} }
@ -500,7 +500,7 @@ func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentif
Table: existingInterface.GetRoutingTable(), 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) return fmt.Errorf("post-delete hooks failed: %w", err)
} }
@ -519,9 +519,9 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
return nil, fmt.Errorf("interface validation failed: %w", err) 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) return nil, fmt.Errorf("pre-save hooks failed: %w", err)
} }
@ -561,7 +561,7 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
m.bus.Publish(app.TopicRouteUpdate, "interface updated: "+string(iface.Identifier)) 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) return nil, fmt.Errorf("post-save hooks failed: %w", err)
} }
@ -576,32 +576,13 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
return iface, nil 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) oldInterface, err := m.db.GetInterface(ctx, iface.Identifier)
if err != nil { 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 !oldInterface.IsDisabled(), !iface.IsDisabled()
return true // interface in db has changed
}
wgInterface, err := m.wg.GetController(*iface).GetInterface(ctx, iface.Identifier)
if err != nil {
return true // interface might not exist - so we assume that there must be a change
}
// compare physical interface settings
if len(wgInterface.Addresses) != len(iface.Addresses) ||
wgInterface.Mtu != iface.Mtu ||
wgInterface.FirewallMark != iface.FirewallMark ||
wgInterface.ListenPort != iface.ListenPort ||
wgInterface.PrivateKey != iface.PrivateKey ||
wgInterface.PublicKey != iface.PublicKey {
return true
}
return false
} }
func (m Manager) handleInterfacePreSaveActions(iface *domain.Interface) error { func (m Manager) handleInterfacePreSaveActions(iface *domain.Interface) error {
@ -617,12 +598,14 @@ func (m Manager) handleInterfacePreSaveActions(iface *domain.Interface) error {
return nil return nil
} }
func (m Manager) handleInterfacePreSaveHooks(stateChanged bool, iface *domain.Interface) error { func (m Manager) handleInterfacePreSaveHooks(iface *domain.Interface, oldEnabled, newEnabled bool) error {
if !stateChanged { if oldEnabled == newEnabled {
return nil // do nothing if state did not change 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 { if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PreUp); err != nil {
return fmt.Errorf("failed to execute pre-up hook: %w", err) return fmt.Errorf("failed to execute pre-up hook: %w", err)
} }
@ -634,12 +617,14 @@ func (m Manager) handleInterfacePreSaveHooks(stateChanged bool, iface *domain.In
return nil return nil
} }
func (m Manager) handleInterfacePostSaveHooks(stateChanged bool, iface *domain.Interface) error { func (m Manager) handleInterfacePostSaveHooks(iface *domain.Interface, oldEnabled, newEnabled bool) error {
if !stateChanged { if oldEnabled == newEnabled {
return nil // do nothing if state did not change 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 { if err := m.quick.ExecuteInterfaceHook(iface.Identifier, iface.PostUp); err != nil {
return fmt.Errorf("failed to execute post-up hook: %w", err) return fmt.Errorf("failed to execute post-up hook: %w", err)
} }

View File

@ -188,6 +188,30 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
sessionUser := domain.GetUserInfo(ctx) sessionUser := domain.GetUserInfo(ctx)
// Enforce peer limit for non-admin users if LimitAdditionalUserPeers is set
if m.cfg.Core.SelfProvisioningAllowed && !sessionUser.IsAdmin && m.cfg.Advanced.LimitAdditionalUserPeers > 0 {
peers, err := m.db.GetUserPeers(ctx, peer.UserIdentifier)
if err != nil {
return nil, fmt.Errorf("failed to fetch peers for user %s: %w", peer.UserIdentifier, err)
}
// Count enabled peers (disabled IS NULL)
peerCount := 0
for _, p := range peers {
if !p.IsDisabled() {
peerCount++
}
}
totalAllowedPeers := 1 + m.cfg.Advanced.LimitAdditionalUserPeers // 1 default peer + x additional peers
if peerCount >= totalAllowedPeers {
slog.WarnContext(ctx, "peer creation blocked due to limit",
"user", peer.UserIdentifier,
"current_count", peerCount,
"allowed_count", totalAllowedPeers)
return nil, fmt.Errorf("peer limit reached (%d peers allowed): %w", totalAllowedPeers, domain.ErrNoPermission)
}
}
existingPeer, err := m.db.GetPeer(ctx, peer.Identifier) existingPeer, err := m.db.GetPeer(ctx, peer.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) { if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("unable to load existing peer %s: %w", peer.Identifier, err) return nil, fmt.Errorf("unable to load existing peer %s: %w", peer.Identifier, err)

View File

@ -21,6 +21,9 @@ type Auth struct {
// MinPasswordLength is the minimum password length for user accounts. This also applies to the admin user. // MinPasswordLength is the minimum password length for user accounts. This also applies to the admin user.
// It is encouraged to set this value to at least 16 characters. // It is encouraged to set this value to at least 16 characters.
MinPasswordLength int `yaml:"min_password_length"` MinPasswordLength int `yaml:"min_password_length"`
// HideLoginForm specifies whether the login form should be hidden. If no social login providers are configured,
// the login form will be shown regardless of this setting.
HideLoginForm bool `yaml:"hide_login_form"`
} }
// BaseFields contains the basic fields that are used to map user information from the authentication providers. // BaseFields contains the basic fields that are used to map user information from the authentication providers.

View File

@ -29,18 +29,19 @@ type Config struct {
} `yaml:"core"` } `yaml:"core"`
Advanced struct { Advanced struct {
LogLevel string `yaml:"log_level"` LogLevel string `yaml:"log_level"`
LogPretty bool `yaml:"log_pretty"` LogPretty bool `yaml:"log_pretty"`
LogJson bool `yaml:"log_json"` LogJson bool `yaml:"log_json"`
StartListenPort int `yaml:"start_listen_port"` StartListenPort int `yaml:"start_listen_port"`
StartCidrV4 string `yaml:"start_cidr_v4"` StartCidrV4 string `yaml:"start_cidr_v4"`
StartCidrV6 string `yaml:"start_cidr_v6"` StartCidrV6 string `yaml:"start_cidr_v6"`
UseIpV6 bool `yaml:"use_ip_v6"` UseIpV6 bool `yaml:"use_ip_v6"`
ConfigStoragePath string `yaml:"config_storage_path"` // keep empty to disable config export to file ConfigStoragePath string `yaml:"config_storage_path"` // keep empty to disable config export to file
ExpiryCheckInterval time.Duration `yaml:"expiry_check_interval"` ExpiryCheckInterval time.Duration `yaml:"expiry_check_interval"`
RulePrioOffset int `yaml:"rule_prio_offset"` RulePrioOffset int `yaml:"rule_prio_offset"`
RouteTableOffset int `yaml:"route_table_offset"` RouteTableOffset int `yaml:"route_table_offset"`
ApiAdminOnly bool `yaml:"api_admin_only"` // if true, only admin users can access the API ApiAdminOnly bool `yaml:"api_admin_only"` // if true, only admin users can access the API
LimitAdditionalUserPeers int `yaml:"limit_additional_user_peers"`
} `yaml:"advanced"` } `yaml:"advanced"`
Backend Backend `yaml:"backend"` Backend Backend `yaml:"backend"`
@ -78,6 +79,7 @@ func (c *Config) LogStartupValues() {
"reEnablePeerAfterUserEnable", c.Core.ReEnablePeerAfterUserEnable, "reEnablePeerAfterUserEnable", c.Core.ReEnablePeerAfterUserEnable,
"deletePeerAfterUserDeleted", c.Core.DeletePeerAfterUserDeleted, "deletePeerAfterUserDeleted", c.Core.DeletePeerAfterUserDeleted,
"selfProvisioningAllowed", c.Core.SelfProvisioningAllowed, "selfProvisioningAllowed", c.Core.SelfProvisioningAllowed,
"limitAdditionalUserPeers", c.Advanced.LimitAdditionalUserPeers,
"importExisting", c.Core.ImportExisting, "importExisting", c.Core.ImportExisting,
"restoreState", c.Core.RestoreState, "restoreState", c.Core.RestoreState,
"useIpV6", c.Advanced.UseIpV6, "useIpV6", c.Advanced.UseIpV6,
@ -95,6 +97,9 @@ func (c *Config) LogStartupValues() {
"oidcProviders", len(c.Auth.OpenIDConnect), "oidcProviders", len(c.Auth.OpenIDConnect),
"oauthProviders", len(c.Auth.OAuth), "oauthProviders", len(c.Auth.OAuth),
"ldapProviders", len(c.Auth.Ldap), "ldapProviders", len(c.Auth.Ldap),
"webauthnEnabled", c.Auth.WebAuthn.Enabled,
"minPasswordLength", c.Auth.MinPasswordLength,
"hideLoginForm", c.Auth.HideLoginForm,
) )
slog.Debug("Config Backend", slog.Debug("Config Backend",
@ -149,6 +154,7 @@ func defaultConfig() *Config {
cfg.Advanced.RulePrioOffset = 20000 cfg.Advanced.RulePrioOffset = 20000
cfg.Advanced.RouteTableOffset = 20000 cfg.Advanced.RouteTableOffset = 20000
cfg.Advanced.ApiAdminOnly = true cfg.Advanced.ApiAdminOnly = true
cfg.Advanced.LimitAdditionalUserPeers = 0
cfg.Statistics.UsePingChecks = true cfg.Statistics.UsePingChecks = true
cfg.Statistics.PingCheckWorkers = 10 cfg.Statistics.PingCheckWorkers = 10
@ -178,6 +184,7 @@ func defaultConfig() *Config {
cfg.Auth.WebAuthn.Enabled = true cfg.Auth.WebAuthn.Enabled = true
cfg.Auth.MinPasswordLength = 16 cfg.Auth.MinPasswordLength = 16
cfg.Auth.HideLoginForm = false
return cfg return cfg
} }

View File

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

View File

@ -136,6 +136,7 @@ func (p *Peer) OverwriteUserEditableFields(userPeer *Peer, cfg *config.Config) {
p.Interface.PublicKey = userPeer.Interface.PublicKey p.Interface.PublicKey = userPeer.Interface.PublicKey
p.Interface.PrivateKey = userPeer.Interface.PrivateKey p.Interface.PrivateKey = userPeer.Interface.PrivateKey
p.PresharedKey = userPeer.PresharedKey p.PresharedKey = userPeer.PresharedKey
p.Identifier = userPeer.Identifier
} }
p.Interface.Mtu = userPeer.Interface.Mtu p.Interface.Mtu = userPeer.Interface.Mtu
p.PersistentKeepalive = userPeer.PersistentKeepalive p.PersistentKeepalive = userPeer.PersistentKeepalive

View File

@ -5,21 +5,23 @@ import (
) )
type PeerStatus struct { type PeerStatus struct {
PeerId PeerIdentifier `gorm:"primaryKey;column:identifier"` PeerId PeerIdentifier `gorm:"primaryKey;column:identifier" json:"PeerId"`
UpdatedAt time.Time `gorm:"column:updated_at"` UpdatedAt time.Time `gorm:"column:updated_at" json:"-"`
IsPingable bool `gorm:"column:pingable"` IsConnected bool `gorm:"column:connected" json:"IsConnected"` // indicates if the peer is connected based on the last handshake or ping
LastPing *time.Time `gorm:"column:last_ping"`
BytesReceived uint64 `gorm:"column:received"` IsPingable bool `gorm:"column:pingable" json:"IsPingable"`
BytesTransmitted uint64 `gorm:"column:transmitted"` LastPing *time.Time `gorm:"column:last_ping" json:"LastPing"`
LastHandshake *time.Time `gorm:"column:last_handshake"` BytesReceived uint64 `gorm:"column:received" json:"BytesReceived"`
Endpoint string `gorm:"column:endpoint"` BytesTransmitted uint64 `gorm:"column:transmitted" json:"BytesTransmitted"`
LastSessionStart *time.Time `gorm:"column:last_session_start"`
LastHandshake *time.Time `gorm:"column:last_handshake" json:"LastHandshake"`
Endpoint string `gorm:"column:endpoint" json:"Endpoint"`
LastSessionStart *time.Time `gorm:"column:last_session_start" json:"LastSessionStart"`
} }
func (s PeerStatus) IsConnected() bool { func (s *PeerStatus) CalcConnected() {
oldestHandshakeTime := time.Now().Add(-2 * time.Minute) // if a handshake is older than 2 minutes, the peer is no longer connected oldestHandshakeTime := time.Now().Add(-2 * time.Minute) // if a handshake is older than 2 minutes, the peer is no longer connected
handshakeValid := false handshakeValid := false
@ -27,7 +29,7 @@ func (s PeerStatus) IsConnected() bool {
handshakeValid = !s.LastHandshake.Before(oldestHandshakeTime) handshakeValid = !s.LastHandshake.Before(oldestHandshakeTime)
} }
return s.IsPingable || handshakeValid s.IsConnected = s.IsPingable || handshakeValid
} }
type InterfaceStatus struct { type InterfaceStatus struct {

View File

@ -66,8 +66,9 @@ func TestPeerStatus_IsConnected(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if got := tt.status.IsConnected(); got != tt.want { tt.status.CalcConnected()
t.Errorf("IsConnected() = %v, want %v", got, tt.want) if got := tt.status.IsConnected; got != tt.want {
t.Errorf("IsConnected = %v, want %v", got, tt.want)
} }
}) })
} }

View File

@ -82,6 +82,7 @@ nav:
- General: documentation/usage/general.md - General: documentation/usage/general.md
- LDAP: documentation/usage/ldap.md - LDAP: documentation/usage/ldap.md
- Security: documentation/usage/security.md - Security: documentation/usage/security.md
- Webhooks: documentation/usage/webhooks.md
- REST API: documentation/rest-api/api-doc.md - REST API: documentation/rest-api/api-doc.md
- Upgrade: documentation/upgrade/v1.md - Upgrade: documentation/upgrade/v1.md
- Monitoring: documentation/monitoring/prometheus.md - Monitoring: documentation/monitoring/prometheus.md