Compare commits

...

5 Commits

Author SHA1 Message Date
Christoph Haas
6888f79727 support for raw-wireguard and wg-quick style peer configurations (#441) 2025-06-28 00:13:12 +02:00
h44z
dd28a8dddf allow to hide login form (#459) (#470)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
use the `hide_login_form` parameter in the `auth` settings to configure this feature
2025-06-27 13:50:38 +02:00
dependabot[bot]
f994700caf chore(deps): bump alpine from 3.19 to 3.22 (#465)
Bumps alpine from 3.19 to 3.22.

---
updated-dependencies:
- dependency-name: alpine
  dependency-version: '3.22'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-27 12:38:18 +02:00
h44z
be29abd29a add webhook event for peer state change (#444) (#468)
* add webhook event for peer state change (#444)

new event types: connect and disconnect

example payload:

```json
{
  "event": "connect",
  "entity": "peer",
  "identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
  "payload": {
    "PeerId": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
    "IsConnected": true,
    "IsPingable": false,
    "LastPing": null,
    "BytesReceived": 1860,
    "BytesTransmitted": 10824,
    "LastHandshake": "2025-06-26T23:04:33.325216659+02:00",
    "Endpoint": "10.55.66.77:33874",
    "LastSessionStart": "2025-06-26T22:50:40.10221606+02:00"
  }
}
```

* add webhook docs (#444)
2025-06-27 12:37:10 +02:00
h44z
94785c10ec use website title in mail templates (#448) (#466)
* use website title in mail templates (#448)

* change button font color to white (#448)
2025-06-27 11:45:44 +02:00
36 changed files with 383 additions and 91 deletions

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

@@ -76,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
@@ -354,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
@@ -669,7 +676,7 @@ The webhook section allows you to configure a webhook that is called on certain
A JSON object is sent in a POST request to the webhook URL with the following structure: A JSON object is sent in a POST request to the webhook URL with the following structure:
```json ```json
{ {
"event": "peer_created", "event": "update",
"entity": "peer", "entity": "peer",
"identifier": "the-peer-identifier", "identifier": "the-peer-identifier",
"payload": { "payload": {
@@ -679,6 +686,8 @@ A JSON object is sent in a POST request to the webhook URL with the following st
} }
``` ```
Further details can be found in the [usage documentation](../usage/webhooks.md).
### `url` ### `url`
- **Default:** *(empty)* - **Default:** *(empty)*
- **Description:** The POST endpoint to which the webhook is sent. The URL must be reachable from the WireGuard Portal server. If the URL is empty, the webhook is disabled. - **Description:** The POST endpoint to which the webhook is sent. The URL must be reachable from the WireGuard Portal server. If the URL is empty, the webhook is disabled.

View File

@@ -0,0 +1,86 @@
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 types can trigger webhooks:
- `user`: When a WireGuard Portal user is created, updated, or deleted.
- `peer`: When a peer is created, updated, or deleted. This entity can also trigger `connect` and `disconnect` events.
- `interface`: When a device is created, updated, or deleted.
## 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",
"entity": "user",
"identifier": "the-user-identifier",
"payload": {
// The payload of the event, e.g. peer data.
// Check the API documentation for the exact structure.
}
}
```
### Example Payload
The following payload is an example of a webhook event when a peer connects to the VPN:
```json
{
"event": "connect",
"entity": "peer",
"identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
"payload": {
"PeerId": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
"IsConnected": true,
"IsPingable": false,
"LastPing": null,
"BytesReceived": 1860,
"BytesTransmitted": 10824,
"LastHandshake": "2025-06-26T23:04:33.325216659+02:00",
"Endpoint": "10.55.66.77:33874",
"LastSessionStart": "2025-06-26T22:50:40.10221606+02:00"
}
}
```

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-primary 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-primary 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

@@ -467,7 +467,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

@@ -468,7 +468,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

@@ -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

@@ -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

@@ -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,6 +2249,9 @@
"ApiAdminOnly": { "ApiAdminOnly": {
"type": "boolean" "type": "boolean"
}, },
"LoginFormVisible": {
"type": "boolean"
},
"MailLinkOnly": { "MailLinkOnly": {
"type": "boolean" "type": "boolean"
}, },

View File

@@ -381,6 +381,8 @@ definitions:
properties: properties:
ApiAdminOnly: ApiAdminOnly:
type: boolean type: boolean
LoginFormVisible:
type: boolean
MailLinkOnly: MailLinkOnly:
type: boolean type: boolean
MinPasswordLength: MinPasswordLength:
@@ -1070,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:
@@ -1095,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
@@ -1123,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

@@ -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

@@ -96,10 +96,13 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
sessionUser := domain.GetUserInfo(r.Context()) sessionUser := domain.GetUserInfo(r.Context())
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,
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
}) })
} else { } else {
respond.JSON(w, http.StatusOK, model.Settings{ respond.JSON(w, http.StatusOK, model.Settings{
@@ -109,6 +112,7 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly, ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly,
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
MinPasswordLength: e.cfg.Auth.MinPasswordLength, MinPasswordLength: e.cfg.Auth.MinPasswordLength,
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

@@ -12,4 +12,5 @@ type Settings struct {
ApiAdminOnly bool `json:"ApiAdminOnly"` ApiAdminOnly bool `json:"ApiAdminOnly"`
WebAuthnEnabled bool `json:"WebAuthnEnabled"` WebAuthnEnabled bool `json:"WebAuthnEnabled"`
MinPasswordLength int `json:"MinPasswordLength"` MinPasswordLength int `json:"MinPasswordLength"`
LoginFormVisible bool `json:"LoginFormVisible"`
} }

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

@@ -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,10 +55,11 @@ 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{
"Style": style,
"Peer": peer, "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

@@ -64,6 +64,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)
@@ -135,6 +136,14 @@ func (m Manager) handleInterfaceDeleteEvent(iface domain.Interface) {
m.handleGenericEvent(WebhookEventDelete, iface) m.handleGenericEvent(WebhookEventDelete, iface)
} }
func (m Manager) handlePeerStateChangeEvent(peerStatus domain.PeerStatus) {
if peerStatus.IsConnected {
m.handleGenericEvent(WebhookEventConnect, peerStatus)
} else {
m.handleGenericEvent(WebhookEventDisconnect, peerStatus)
}
}
func (m Manager) handleGenericEvent(action WebhookEvent, payload any) { func (m Manager) handleGenericEvent(action WebhookEvent, payload any) {
eventData, err := m.createWebhookData(action, payload) eventData, err := m.createWebhookData(action, payload)
if err != nil { if err != nil {
@@ -177,6 +186,9 @@ func (m Manager) createWebhookData(action WebhookEvent, payload any) (*WebhookDa
case domain.Interface: case domain.Interface:
d.Entity = WebhookEntityInterface d.Entity = WebhookEntityInterface
d.Identifier = string(v.Identifier) d.Identifier = string(v.Identifier)
case domain.PeerStatus:
d.Entity = WebhookEntityPeer
d.Identifier = string(v.PeerId)
default: default:
return nil, fmt.Errorf("unsupported payload type: %T", v) return nil, fmt.Errorf("unsupported payload type: %T", v)
} }

View File

@@ -45,4 +45,6 @@ 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

@@ -43,6 +43,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 StatisticsCollector struct { type StatisticsCollector struct {
@@ -55,6 +57,8 @@ type StatisticsCollector struct {
db StatisticsDatabaseRepo db StatisticsDatabaseRepo
wg StatisticsInterfaceController wg StatisticsInterfaceController
ms StatisticsMetricsServer ms StatisticsMetricsServer
peerChangeEvent chan domain.PeerIdentifier
} }
// NewStatisticsCollector creates a new statistics collector. // NewStatisticsCollector creates a new statistics collector.
@@ -171,8 +175,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
@@ -186,6 +194,12 @@ 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 {
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)
@@ -197,6 +211,11 @@ 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 {
// publish event if connection state changed
c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus)
}
} }
} }
} }
@@ -298,12 +317,17 @@ func (c *StatisticsCollector) enqueuePingChecks(ctx context.Context) {
func (c *StatisticsCollector) pingWorker(ctx context.Context) { func (c *StatisticsCollector) pingWorker(ctx context.Context) {
defer c.pingWaitGroup.Done() defer c.pingWaitGroup.Done()
for peer := range c.pingJobs { for peer := range c.pingJobs {
var connectionStateChanged bool
var newPeerStatus domain.PeerStatus
peerPingable := c.isPeerPingable(ctx, peer) peerPingable := c.isPeerPingable(ctx, 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
@@ -311,6 +335,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)
@@ -322,6 +353,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)
}
} }
} }

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

@@ -95,6 +95,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,
) )
} }
@@ -169,6 +172,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

@@ -3,21 +3,23 @@ package domain
import "time" import "time"
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
@@ -25,7 +27,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