Compare commits

...

5 Commits

Author SHA1 Message Date
Christoph Haas
4552240c56 only execute interface hooks if the state has changed (#469) 2025-06-27 22:53:24 +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
26 changed files with 252 additions and 73 deletions

View File

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

View File

@@ -76,6 +76,7 @@ auth:
webauthn:
enabled: true
min_password_length: 16
hide_login_form: false
web:
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.
- **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
@@ -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:
```json
{
"event": "peer_created",
"event": "update",
"entity": "peer",
"identifier": "the-peer-identifier",
"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`
- **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.

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

@@ -16,7 +16,10 @@ const password = ref("")
const usernameInvalid = computed(() => username.value === "")
const passwordInvalid = computed(() => password.value === "")
const disableLoginBtn = computed(() => username.value === "" || password.value === "" || loggingIn.value)
const showLoginForm = computed(() => {
console.log(router.currentRoute.value.query)
return settings.Setting('LoginFormVisible') || router.currentRoute.value.query.hasOwnProperty('all');
});
onMounted(async () => {
await settings.LoadSettings()
@@ -98,7 +101,7 @@ const externalLogin = function (provider) {
</div></div>
<div class="card-body">
<form method="post">
<fieldset>
<fieldset v-if="showLoginForm">
<div class="form-group">
<label class="form-label" for="inputUsername">{{ $t('login.username.label') }}</label>
<div class="input-group mb-3">
@@ -118,19 +121,40 @@ const externalLogin = function (provider) {
</div>
<div class="row mt-5 mb-2">
<div class="col-lg-4">
<button :disabled="disableLoginBtn" class="btn btn-primary" type="submit" @click.prevent="login">
<div class="col-sm-4 col-xs-12">
<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>
</button>
</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">
{{ $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-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">
<!-- OpenIdConnect / OAUTH providers -->
<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>
</form>
</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.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

@@ -2231,6 +2231,9 @@
"ApiAdminOnly": {
"type": "boolean"
},
"LoginFormVisible": {
"type": "boolean"
},
"MailLinkOnly": {
"type": "boolean"
},

View File

@@ -381,6 +381,8 @@ definitions:
properties:
ApiAdminOnly:
type: boolean
LoginFormVisible:
type: boolean
MailLinkOnly:
type: boolean
MinPasswordLength:

View File

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

View File

@@ -12,4 +12,5 @@ type Settings struct {
ApiAdminOnly bool `json:"ApiAdminOnly"`
WebAuthnEnabled bool `json:"WebAuthnEnabled"`
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 {
stats[string(srcStat.PeerId)] = PeerStatData{
IsConnected: srcStat.IsConnected(),
IsConnected: srcStat.IsConnected,
IsPingable: srcStat.IsPingable,
LastPing: srcStat.LastPing,
BytesReceived: srcStat.BytesReceived,

View File

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

View File

@@ -71,7 +71,7 @@ func NewMailManager(
users UserDatabaseRepo,
wg WireguardDatabaseRepo,
) (*Manager, error) {
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl)
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle)
if err != nil {
return nil, fmt.Errorf("failed to initialize template handler: %w", err)
}

View File

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

View File

@@ -19,7 +19,7 @@
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" />
<!--<![endif]-->
<title>Email Template</title>
<title>{{$.PortalName}}</title>
<!--[if gte mso 9]>
<style type="text/css" media="all">
sup { font-size: 100% !important; }
@@ -143,7 +143,7 @@
<td align="left">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="blue-button text-button" style="background:#000000; color:#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>
</table>
</td>
@@ -167,10 +167,10 @@
<td class="p30-15 bbrr" style="padding: 50px 30px; border-radius:0px 0px 26px 26px;" bgcolor="#ffffff">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated 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>
<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>
</table>
</td>

View File

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

View File

@@ -19,7 +19,7 @@
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" />
<!--<![endif]-->
<title>Email Template</title>
<title>{{$.PortalName}}</title>
<!--[if gte mso 9]>
<style type="text/css" media="all">
sup { font-size: 100% !important; }
@@ -143,7 +143,7 @@
<td align="left">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="blue-button text-button" style="background:#000000; color:#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>
</table>
</td>
@@ -167,10 +167,10 @@
<td class="p30-15 bbrr" style="padding: 50px 30px; border-radius:0px 0px 26px 26px;" bgcolor="#ffffff">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated 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>
<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>
</table>
</td>

View File

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

View File

@@ -64,6 +64,7 @@ func (m Manager) connectToMessageBus() {
_ = m.bus.Subscribe(app.TopicPeerCreated, m.handlePeerCreateEvent)
_ = m.bus.Subscribe(app.TopicPeerUpdated, m.handlePeerUpdateEvent)
_ = m.bus.Subscribe(app.TopicPeerDeleted, m.handlePeerDeleteEvent)
_ = m.bus.Subscribe(app.TopicPeerStateChanged, m.handlePeerStateChangeEvent)
_ = m.bus.Subscribe(app.TopicInterfaceCreated, m.handleInterfaceCreateEvent)
_ = m.bus.Subscribe(app.TopicInterfaceUpdated, m.handleInterfaceUpdateEvent)
@@ -135,6 +136,14 @@ func (m Manager) handleInterfaceDeleteEvent(iface domain.Interface) {
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) {
eventData, err := m.createWebhookData(action, payload)
if err != nil {
@@ -177,6 +186,9 @@ func (m Manager) createWebhookData(action WebhookEvent, payload any) (*WebhookDa
case domain.Interface:
d.Entity = WebhookEntityInterface
d.Identifier = string(v.Identifier)
case domain.PeerStatus:
d.Entity = WebhookEntityPeer
d.Identifier = string(v.PeerId)
default:
return nil, fmt.Errorf("unsupported payload type: %T", v)
}

View File

@@ -42,7 +42,9 @@ const (
type WebhookEvent = string
const (
WebhookEventCreate WebhookEvent = "create"
WebhookEventUpdate WebhookEvent = "update"
WebhookEventDelete WebhookEvent = "delete"
WebhookEventCreate WebhookEvent = "create"
WebhookEventUpdate WebhookEvent = "update"
WebhookEventDelete WebhookEvent = "delete"
WebhookEventConnect WebhookEvent = "connect"
WebhookEventDisconnect WebhookEvent = "disconnect"
)

View File

@@ -43,6 +43,8 @@ type StatisticsMetricsServer interface {
type StatisticsEventBus interface {
// Subscribe subscribes to a topic
Subscribe(topic string, fn interface{}) error
// Publish sends a message to the message bus.
Publish(topic string, args ...any)
}
type StatisticsCollector struct {
@@ -55,6 +57,8 @@ type StatisticsCollector struct {
db StatisticsDatabaseRepo
wg StatisticsInterfaceController
ms StatisticsMetricsServer
peerChangeEvent chan domain.PeerIdentifier
}
// NewStatisticsCollector creates a new statistics collector.
@@ -171,8 +175,12 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
continue
}
for _, peer := range peers {
var connectionStateChanged bool
var newPeerStatus domain.PeerStatus
err = c.db.UpdatePeerStatus(ctx, peer.Identifier,
func(p *domain.PeerStatus) (*domain.PeerStatus, error) {
wasConnected := p.IsConnected
var lastHandshake *time.Time
if !peer.LastHandshake.IsZero() {
lastHandshake = &peer.LastHandshake
@@ -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.Endpoint = peer.Endpoint
p.LastHandshake = lastHandshake
p.CalcConnected()
if wasConnected != p.IsConnected {
connectionStateChanged = true
newPeerStatus = *p // store new status for event publishing
}
// Update prometheus metrics
go c.updatePeerMetrics(ctx, *p)
@@ -197,6 +211,11 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
} else {
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) {
defer c.pingWaitGroup.Done()
for peer := range c.pingJobs {
var connectionStateChanged bool
var newPeerStatus domain.PeerStatus
peerPingable := c.isPeerPingable(ctx, peer)
slog.Debug("peer ping check completed", "peer", peer.Identifier, "pingable", peerPingable)
now := time.Now()
err := c.db.UpdatePeerStatus(ctx, peer.Identifier,
func(p *domain.PeerStatus) (*domain.PeerStatus, error) {
wasConnected := p.IsConnected
if peerPingable {
p.IsPingable = true
p.LastPing = &now
@@ -311,6 +335,13 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) {
p.IsPingable = false
p.LastPing = nil
}
p.UpdatedAt = time.Now()
p.CalcConnected()
if wasConnected != p.IsConnected {
connectionStateChanged = true
newPeerStatus = *p // store new status for event publishing
}
// Update prometheus metrics
go c.updatePeerMetrics(ctx, *p)
@@ -322,6 +353,11 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) {
} else {
slog.Debug("updated peer ping status", "peer", peer.Identifier)
}
if connectionStateChanged {
// publish event if connection state changed
c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus)
}
}
}

View File

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

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.
// It is encouraged to set this value to at least 16 characters.
MinPasswordLength int `yaml:"min_password_length"`
// HideLoginForm specifies whether the login form should be hidden. If no social login providers are configured,
// the login form will be shown regardless of this setting.
HideLoginForm bool `yaml:"hide_login_form"`
}
// BaseFields contains the basic fields that are used to map user information from the authentication providers.

View File

@@ -95,6 +95,9 @@ func (c *Config) LogStartupValues() {
"oidcProviders", len(c.Auth.OpenIDConnect),
"oauthProviders", len(c.Auth.OAuth),
"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.MinPasswordLength = 16
cfg.Auth.HideLoginForm = false
return cfg
}

View File

@@ -3,21 +3,23 @@ package domain
import "time"
type PeerStatus struct {
PeerId PeerIdentifier `gorm:"primaryKey;column:identifier"`
UpdatedAt time.Time `gorm:"column:updated_at"`
PeerId PeerIdentifier `gorm:"primaryKey;column:identifier" json:"PeerId"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"-"`
IsPingable bool `gorm:"column:pingable"`
LastPing *time.Time `gorm:"column:last_ping"`
IsConnected bool `gorm:"column:connected" json:"IsConnected"` // indicates if the peer is connected based on the last handshake or ping
BytesReceived uint64 `gorm:"column:received"`
BytesTransmitted uint64 `gorm:"column:transmitted"`
IsPingable bool `gorm:"column:pingable" json:"IsPingable"`
LastPing *time.Time `gorm:"column:last_ping" json:"LastPing"`
LastHandshake *time.Time `gorm:"column:last_handshake"`
Endpoint string `gorm:"column:endpoint"`
LastSessionStart *time.Time `gorm:"column:last_session_start"`
BytesReceived uint64 `gorm:"column:received" json:"BytesReceived"`
BytesTransmitted uint64 `gorm:"column:transmitted" json:"BytesTransmitted"`
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
handshakeValid := false
@@ -25,7 +27,7 @@ func (s PeerStatus) IsConnected() bool {
handshakeValid = !s.LastHandshake.Before(oldestHandshakeTime)
}
return s.IsPingable || handshakeValid
s.IsConnected = s.IsPingable || handshakeValid
}
type InterfaceStatus struct {

View File

@@ -66,8 +66,9 @@ func TestPeerStatus_IsConnected(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.status.IsConnected(); got != tt.want {
t.Errorf("IsConnected() = %v, want %v", got, tt.want)
tt.status.CalcConnected()
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
- LDAP: documentation/usage/ldap.md
- Security: documentation/usage/security.md
- Webhooks: documentation/usage/webhooks.md
- REST API: documentation/rest-api/api-doc.md
- Upgrade: documentation/upgrade/v1.md
- Monitoring: documentation/monitoring/prometheus.md