Compare commits

..

5 Commits

Author SHA1 Message Date
Christoph Haas
139fb17f98 redo UI screenshots, fix the responsiveness of the image slider for wgportal.org
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-10-12 15:48:08 +02:00
Christoph Haas
faf1d995a8 fix parsing IP addresses in UI (ip-address lib was updated to V10) 2025-10-12 15:20:38 +02:00
Christoph Haas
f53d0b3d7f add the possibility to debug oauth or oidc login issues (#541) 2025-10-12 15:09:40 +02:00
h44z
cdf3a49801 Cleanup route handling (#542)
* mikrotik: allow to set DNS, wip: handle routes in wg-controller

* replace old route handling for local controller

* cleanup route handling for local backend

* implement route handling for mikrotik controller
2025-10-12 14:31:19 +02:00
Christoph Haas
298c9405f6 add support for sending emails to peers without linked user accounts if their user-identifier is a valid email address 2025-10-12 14:31:01 +02:00
10 changed files with 132 additions and 55 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 131 KiB

View File

@@ -73,6 +73,7 @@ mail:
auth_type: plain auth_type: plain
from: Wireguard Portal <noreply@wireguard.local> from: Wireguard Portal <noreply@wireguard.local>
link_only: false link_only: false
allow_peer_email: false
auth: auth:
oidc: [] oidc: []
@@ -386,7 +387,9 @@ Controls how WireGuard Portal collects and reports usage statistics, including p
## Mail ## Mail
Options for configuring email notifications or sending peer configurations via email. Options for configuring email notifications or sending peer configurations via email.
By default, emails will only be sent to peers that have a valid user record linked.
To send emails to all peers that have a valid email-address as user-identifier, set `allow_peer_email` to `true`.
### `host` ### `host`
- **Default:** `127.0.0.1` - **Default:** `127.0.0.1`
@@ -424,6 +427,12 @@ Options for configuring email notifications or sending peer configurations via e
- **Default:** `false` - **Default:** `false`
- **Description:** If `true`, emails only contain a link to WireGuard Portal, rather than attaching the full configuration. - **Description:** If `true`, emails only contain a link to WireGuard Portal, rather than attaching the full configuration.
### `allow_peer_email`
- **Default:** `false`
- **Description:** If `true`, and a peer has no valid user record linked, but the user-identifier of the peer is a valid email address, emails will be sent to that email address.
If false, and the peer has no valid user record linked, emails will not be sent.
If a peer has linked a valid user, the email address is always taken from the user record.
--- ---
## Auth ## Auth
@@ -503,13 +512,18 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex. - `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
#### `registration_enabled` #### `registration_enabled`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, a new user will be created in WireGuard Portal if not already present. - **Description:** If `true`, a new user will be created in WireGuard Portal if not already present.
#### `log_user_info` #### `log_user_info`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, OIDC user data is logged at the trace level upon login (for debugging). - **Description:** If `true`, OIDC user data is logged at the trace level upon login (for debugging).
#### `log_sensitive_info`
- **Default:** `false`
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
--- ---
### OAuth ### OAuth
@@ -576,13 +590,18 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex. - `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
#### `registration_enabled` #### `registration_enabled`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, new users are created automatically on successful login. - **Description:** If `true`, new users are created automatically on successful login.
#### `log_user_info` #### `log_user_info`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, logs user info at the trace level upon login. - **Description:** If `true`, logs user info at the trace level upon login.
#### `log_sensitive_info`
- **Default:** `false`
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
--- ---
### LDAP ### LDAP
@@ -599,11 +618,11 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
- **Description:** The LDAP server URL (e.g., `ldap://srv-ad01.company.local:389`). - **Description:** The LDAP server URL (e.g., `ldap://srv-ad01.company.local:389`).
#### `start_tls` #### `start_tls`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, use STARTTLS to secure the LDAP connection. - **Description:** If `true`, use STARTTLS to secure the LDAP connection.
#### `cert_validation` #### `cert_validation`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, validate the LDAP servers TLS certificate. - **Description:** If `true`, validate the LDAP servers TLS certificate.
#### `tls_certificate_path` #### `tls_certificate_path`
@@ -673,19 +692,19 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
``` ```
#### `disable_missing` #### `disable_missing`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal. - **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal.
#### `auto_re_enable` #### `auto_re_enable`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, users that where disabled because they were missing (see `disable_missing`) will be re-enabled once they are found again. - **Description:** If `true`, users that where disabled because they were missing (see `disable_missing`) will be re-enabled once they are found again.
#### `registration_enabled` #### `registration_enabled`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login. - **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login.
#### `log_user_info` #### `log_user_info`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, logs LDAP user data at the trace level upon login. - **Description:** If `true`, logs LDAP user data at the trace level upon login.
--- ---

View File

@@ -68,7 +68,7 @@
} }
.tx-hero__image { .tx-hero__image {
max-width: 1000px; max-width: 1000px;
min-width: 600px; min-width: 0;
width: 100%; width: 100%;
height: auto; height: auto;
margin: 0 auto; margin: 0 auto;
@@ -218,7 +218,7 @@
.secondary-section .g .section .component-wrapper .responsive-grid .card { .secondary-section .g .section .component-wrapper .responsive-grid .card {
position: relative; position: relative;
background-color: #fff none repeat scroll 0% 0%; background-color: #fff;
padding: 1.5rem; padding: 1.5rem;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -363,7 +363,6 @@
<h1>A beautiful and simple UI to manage your WireGuard peers and interfaces</h1> <h1>A beautiful and simple UI to manage your WireGuard peers and interfaces</h1>
<p>WireGuard Portal is an open source web-based user interface that makes it easy to setup and manage <p>WireGuard Portal is an open source web-based user interface that makes it easy to setup and manage
WireGuard VPN connections. It's built on top of WireGuard's official <span class="em">wgctrl</span> library.</p> WireGuard VPN connections. It's built on top of WireGuard's official <span class="em">wgctrl</span> library.</p>
</p>
<a <a
href="documentation/overview/" href="documentation/overview/"
title="Get Started" title="Get Started"

View File

@@ -4,12 +4,12 @@ export function ipToBigInt(ip) {
// Check if it's an IPv4 address // Check if it's an IPv4 address
if (ip.includes(".")) { if (ip.includes(".")) {
const addr = new Address4(ip) const addr = new Address4(ip)
return addr.bigInteger() return addr.bigInt()
} }
// Otherwise, assume it's an IPv6 address // Otherwise, assume it's an IPv6 address
const addr = new Address6(ip) const addr = new Address6(ip)
return addr.bigInteger() return addr.bigInt()
} }
export function humanFileSize(size) { export function humanFileSize(size) {

View File

@@ -19,15 +19,16 @@ import (
// PlainOauthAuthenticator is an authenticator that uses OAuth for authentication. // PlainOauthAuthenticator is an authenticator that uses OAuth for authentication.
// User information is retrieved from the specified user info endpoint. // User information is retrieved from the specified user info endpoint.
type PlainOauthAuthenticator struct { type PlainOauthAuthenticator struct {
name string name string
cfg *oauth2.Config cfg *oauth2.Config
userInfoEndpoint string userInfoEndpoint string
client *http.Client client *http.Client
userInfoMapping config.OauthFields userInfoMapping config.OauthFields
userAdminMapping *config.OauthAdminMapping userAdminMapping *config.OauthAdminMapping
registrationEnabled bool registrationEnabled bool
userInfoLogging bool userInfoLogging bool
allowedDomains []string sensitiveInfoLogging bool
allowedDomains []string
} }
func newPlainOauthAuthenticator( func newPlainOauthAuthenticator(
@@ -57,6 +58,7 @@ func newPlainOauthAuthenticator(
provider.userAdminMapping = &cfg.AdminMapping provider.userAdminMapping = &cfg.AdminMapping
provider.registrationEnabled = cfg.RegistrationEnabled provider.registrationEnabled = cfg.RegistrationEnabled
provider.userInfoLogging = cfg.LogUserInfo provider.userInfoLogging = cfg.LogUserInfo
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains provider.allowedDomains = cfg.AllowedDomains
return provider, nil return provider, nil
@@ -110,6 +112,10 @@ func (p PlainOauthAuthenticator) GetUserInfo(
response, err := p.client.Do(req) response, err := p.client.Do(req)
if err != nil { if err != nil {
if p.sensitiveInfoLogging {
slog.Debug("OAuth: failed to get user info", "endpoint", p.userInfoEndpoint,
"token", token, "error", err)
}
return nil, fmt.Errorf("failed to get user info: %w", err) return nil, fmt.Errorf("failed to get user info: %w", err)
} }
defer internal.LogClose(response.Body) defer internal.LogClose(response.Body)
@@ -121,11 +127,15 @@ func (p PlainOauthAuthenticator) GetUserInfo(
var userFields map[string]any var userFields map[string]any
err = json.Unmarshal(contents, &userFields) err = json.Unmarshal(contents, &userFields)
if err != nil { if err != nil {
if p.sensitiveInfoLogging {
slog.Debug("OAuth: failed to parse user info", "endpoint", p.userInfoEndpoint,
"token", token, "contents", contents, "error", err)
}
return nil, fmt.Errorf("failed to parse user info: %w", err) return nil, fmt.Errorf("failed to parse user info: %w", err)
} }
if p.userInfoLogging { if p.userInfoLogging {
slog.Debug("OAuth user info", slog.Debug("OAuth: user info debug",
"source", p.name, "source", p.name,
"info", string(contents)) "info", string(contents))
} }

View File

@@ -16,15 +16,16 @@ import (
// OidcAuthenticator is an authenticator for OpenID Connect providers. // OidcAuthenticator is an authenticator for OpenID Connect providers.
type OidcAuthenticator struct { type OidcAuthenticator struct {
name string name string
provider *oidc.Provider provider *oidc.Provider
verifier *oidc.IDTokenVerifier verifier *oidc.IDTokenVerifier
cfg *oauth2.Config cfg *oauth2.Config
userInfoMapping config.OauthFields userInfoMapping config.OauthFields
userAdminMapping *config.OauthAdminMapping userAdminMapping *config.OauthAdminMapping
registrationEnabled bool registrationEnabled bool
userInfoLogging bool userInfoLogging bool
allowedDomains []string sensitiveInfoLogging bool
allowedDomains []string
} }
func newOidcAuthenticator( func newOidcAuthenticator(
@@ -58,6 +59,7 @@ func newOidcAuthenticator(
provider.userAdminMapping = &cfg.AdminMapping provider.userAdminMapping = &cfg.AdminMapping
provider.registrationEnabled = cfg.RegistrationEnabled provider.registrationEnabled = cfg.RegistrationEnabled
provider.userInfoLogging = cfg.LogUserInfo provider.userInfoLogging = cfg.LogUserInfo
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains provider.allowedDomains = cfg.AllowedDomains
return provider, nil return provider, nil
@@ -102,24 +104,40 @@ func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token,
) { ) {
rawIDToken, ok := token.Extra("id_token").(string) rawIDToken, ok := token.Extra("id_token").(string)
if !ok { if !ok {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: token does not contain id_token", "token", token, "nonce", nonce)
}
return nil, errors.New("token does not contain id_token") return nil, errors.New("token does not contain id_token")
} }
idToken, err := o.verifier.Verify(ctx, rawIDToken) idToken, err := o.verifier.Verify(ctx, rawIDToken)
if err != nil { if err != nil {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: failed to validate id_token", "token", token, "id_token", rawIDToken, "nonce", nonce,
"error",
err)
}
return nil, fmt.Errorf("failed to validate id_token: %w", err) return nil, fmt.Errorf("failed to validate id_token: %w", err)
} }
if idToken.Nonce != nonce { if idToken.Nonce != nonce {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: id_token nonce mismatch", "token", token, "id_token", idToken, "nonce", nonce)
}
return nil, errors.New("nonce mismatch") return nil, errors.New("nonce mismatch")
} }
var tokenFields map[string]any var tokenFields map[string]any
if err = idToken.Claims(&tokenFields); err != nil { if err = idToken.Claims(&tokenFields); err != nil {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: failed to parse extra claims", "token", token, "id_token", idToken, "nonce", nonce,
"error",
err)
}
return nil, fmt.Errorf("failed to parse extra claims: %w", err) return nil, fmt.Errorf("failed to parse extra claims: %w", err)
} }
if o.userInfoLogging { if o.userInfoLogging {
contents, _ := json.Marshal(tokenFields) contents, _ := json.Marshal(tokenFields)
slog.Debug("OIDC user info", slog.Debug("OIDC: user info debug",
"source", o.name, "source", o.name,
"info", string(contents)) "info", string(contents))
} }

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net/mail"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
@@ -101,29 +102,15 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, style string,
} }
if peer.UserIdentifier == "" { if peer.UserIdentifier == "" {
slog.Debug("skipping peer email", return fmt.Errorf("peer %s has no user linked, no email is sent", peerId)
"peer", peerId,
"reason", "no user linked")
continue
} }
user, err := m.users.GetUser(ctx, peer.UserIdentifier) email, user := m.resolveEmail(ctx, peer)
if err != nil { if email == "" {
slog.Debug("skipping peer email", return fmt.Errorf("peer %s has no valid email address, no email is sent", peerId)
"peer", peerId,
"reason", "unable to fetch user",
"error", err)
continue
} }
if user.Email == "" { err = m.sendPeerEmail(ctx, linkOnly, style, &user, peer)
slog.Debug("skipping peer email",
"peer", peerId,
"reason", "user has no mail address")
continue
}
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)
} }
@@ -194,3 +181,37 @@ func (m Manager) sendPeerEmail(
return nil return nil
} }
func (m Manager) resolveEmail(ctx context.Context, peer *domain.Peer) (string, domain.User) {
user, err := m.users.GetUser(ctx, peer.UserIdentifier)
if err != nil {
if m.cfg.Mail.AllowPeerEmail {
_, err := mail.ParseAddress(string(peer.UserIdentifier)) // test if the user identifier is a valid email address
if err == nil {
slog.Debug("peer email: using user-identifier as email",
"peer", peer.Identifier, "email", peer.UserIdentifier)
return string(peer.UserIdentifier), domain.User{}
} else {
slog.Debug("peer email: skipping peer email",
"peer", peer.Identifier,
"reason", "peer has no user linked and user-identifier is not a valid email address")
return "", domain.User{}
}
} else {
slog.Debug("peer email: skipping peer email",
"peer", peer.Identifier,
"reason", "user has no user linked")
return "", domain.User{}
}
}
if user.Email == "" {
slog.Debug("peer email: skipping peer email",
"peer", peer.Identifier,
"reason", "user has no mail address")
return "", domain.User{}
}
slog.Debug("peer email: using user email", "peer", peer.Identifier, "email", user.Email)
return user.Email, *user
}

View File

@@ -211,6 +211,10 @@ type OpenIDConnectProvider struct {
// If LogUserInfo is set to true, the user info retrieved from the OIDC provider will be logged in trace level. // If LogUserInfo is set to true, the user info retrieved from the OIDC provider will be logged in trace level.
LogUserInfo bool `yaml:"log_user_info"` LogUserInfo bool `yaml:"log_user_info"`
// If LogSensitiveInfo is set to true, sensitive information retrieved from the OIDC provider will be logged in trace level.
// This also includes OAuth tokens! Keep this disabled in production!
LogSensitiveInfo bool `yaml:"log_sensitive_info"`
} }
// OAuthProvider contains the configuration for the OAuth provider. // OAuthProvider contains the configuration for the OAuth provider.
@@ -252,6 +256,10 @@ type OAuthProvider struct {
// If LogUserInfo is set to true, the user info retrieved from the OAuth provider will be logged in trace level. // If LogUserInfo is set to true, the user info retrieved from the OAuth provider will be logged in trace level.
LogUserInfo bool `yaml:"log_user_info"` LogUserInfo bool `yaml:"log_user_info"`
// If LogSensitiveInfo is set to true, sensitive information retrieved from the OAuth provider will be logged in trace level.
// This also includes OAuth tokens! Keep this disabled in production!
LogSensitiveInfo bool `yaml:"log_sensitive_info"`
} }
// WebauthnConfig contains the configuration for the WebAuthn authenticator. // WebauthnConfig contains the configuration for the WebAuthn authenticator.

View File

@@ -41,4 +41,6 @@ type MailConfig struct {
From string `yaml:"from"` From string `yaml:"from"`
// LinkOnly specifies whether emails should only contain a link to WireGuard Portal or attach the full configuration // LinkOnly specifies whether emails should only contain a link to WireGuard Portal or attach the full configuration
LinkOnly bool `yaml:"link_only"` LinkOnly bool `yaml:"link_only"`
// AllowPeerEmail specifies whether emails should be sent to peers which have no valid user account linked, but an email address is set as "user".
AllowPeerEmail bool `yaml:"allow_peer_email"`
} }