From 9c56e92443adc3486aa9aeb5fa735d56f0803136 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 17 Mar 2026 18:33:57 +0100 Subject: [PATCH 1/7] Updated documentation (#640) Include pass about systemd networkd managing foreign routes and deleting them on restart. Signed-off-by: Tim Co-authored-by: Tim Aerdts --- docs/documentation/getting-started/docker.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/documentation/getting-started/docker.md b/docs/documentation/getting-started/docker.md index 091aaf7..1176bc1 100644 --- a/docs/documentation/getting-started/docker.md +++ b/docs/documentation/getting-started/docker.md @@ -35,6 +35,14 @@ WireGuard Portal supports managing WireGuard interfaces through three distinct d > :warning: If host networking is used, the WireGuard Portal UI will be accessible on all the host's IP addresses if the listening address is set to `:8888` in the configuration file. To avoid this, you can bind the listening address to a specific IP address, for example, the loopback address (`127.0.0.1:8888`). It is also possible to deploy firewall rules to restrict access to the WireGuard Portal UI. + > :warning: If the host is running **systemd-networkd**, routes managed by WireGuard Portal may be removed whenever systemd-networkd restarts, as it will clean up routes it considers "foreign". To prevent this, add the following to your host's network configuration (e.g. `/etc/systemd/networkd.conf` or a drop-in file): + > ```ini + > [Network] + > ManageForeignRoutingPolicyRules=no + > ManageForeignRoutes=no + > ``` + > After editing, reload the configuration with `sudo systemctl restart systemd-networkd`. For more information refer to the [systemd-networkd documentation](https://www.freedesktop.org/software/systemd/man/latest/networkd.conf.html#ManageForeignRoutes=). + - **Within the WireGuard Portal Docker container**: WireGuard interfaces can be managed directly from within the WireGuard Portal container itself. This is the recommended approach when running WireGuard Portal via Docker, as it encapsulates all functionality in a single, portable container without requiring a separate WireGuard host or image. From 2585be118f4ff2a2b33252e87bd06a31e5d3bcd5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:50:34 +0100 Subject: [PATCH 2/7] chore(deps): bump golang.org/x/oauth2 from 0.35.0 to 0.36.0 (#635) Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.35.0 to 0.36.0. - [Commits](https://github.com/golang/oauth2/compare/v0.35.0...v0.36.0) --- updated-dependencies: - dependency-name: golang.org/x/oauth2 dependency-version: 0.36.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 873f377..7ce7c30 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/yeqown/go-qrcode/v2 v2.2.5 github.com/yeqown/go-qrcode/writer/compressed v1.0.1 golang.org/x/crypto v0.48.0 - golang.org/x/oauth2 v0.35.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/sys v0.41.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 68994fc..fc5f110 100644 --- a/go.sum +++ b/go.sum @@ -305,8 +305,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From f70f60a3f5cdfc109a197aab9928c4f193a3465d Mon Sep 17 00:00:00 2001 From: h44z Date: Thu, 19 Mar 2026 23:11:40 +0100 Subject: [PATCH 3/7] fix: configurable handshake validity interval and improved defaults (#645) * fix: support configurable rekey timeout interval for peer connectivity tracking (#641) * change default check-time to 180s --- docs/documentation/configuration/overview.md | 8 +++ internal/app/wireguard/statistics.go | 10 ++-- internal/app/wireguard/statistics_test.go | 12 +++- internal/config/backend.go | 6 +- internal/config/config.go | 1 + internal/domain/statistics.go | 4 +- internal/domain/statistics_test.go | 60 ++++++++++++++++---- 7 files changed, 79 insertions(+), 22 deletions(-) diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 295e362..cad55c6 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -28,6 +28,7 @@ core: backend: default: local + rekey_timeout_interval: 125s local_resolvconf_prefix: tun. advanced: @@ -203,6 +204,13 @@ The current MikroTik backend is in **BETA** and may not support all features. - **Description:** The default backend to use for managing WireGuard interfaces. Valid options are: `local`, or other backend id's configured in the `mikrotik` section. +### `rekey_timeout_interval` +- **Default:** `180s` +- **Environment Variable:** `WG_PORTAL_BACKEND_REKEY_TIMEOUT_INTERVAL` +- **Description:** The interval after which a WireGuard peer is considered disconnected if no handshake updates are received. + This corresponds to the WireGuard rekey timeout setting of 120 seconds plus a 60-second buffer to account for latency or retry handling. + Uses Go duration format (e.g., `10s`, `1m`). If omitted, a default of 180 seconds is used. + ### `local_resolvconf_prefix` - **Default:** `tun.` - **Environment Variable:** `WG_PORTAL_BACKEND_LOCAL_RESOLVCONF_PREFIX` diff --git a/internal/app/wireguard/statistics.go b/internal/app/wireguard/statistics.go index bbc0764..819bbf1 100644 --- a/internal/app/wireguard/statistics.go +++ b/internal/app/wireguard/statistics.go @@ -204,13 +204,13 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) { // calculate if session was restarted p.UpdatedAt = now - p.LastSessionStart = getSessionStartTime(*p, peer.BytesUpload, peer.BytesDownload, + p.LastSessionStart = c.getSessionStartTime(*p, peer.BytesUpload, peer.BytesDownload, lastHandshake) p.BytesReceived = peer.BytesUpload // store bytes that where uploaded from the peer and received 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.LastHandshake = lastHandshake - p.CalcConnected() + p.CalcConnected(c.cfg.Backend.ReKeyTimeoutInterval) if wasConnected != p.IsConnected { slog.Debug("peer connection state changed", @@ -249,7 +249,7 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) { } } -func getSessionStartTime( +func (c *StatisticsCollector) getSessionStartTime( oldStats domain.PeerStatus, newReceived, newTransmitted uint64, latestHandshake *time.Time, @@ -258,7 +258,7 @@ func getSessionStartTime( return nil // currently not connected } - 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(-1 * c.cfg.Backend.ReKeyTimeoutInterval) // if a handshake is older than the rekey interval + grace-period, the peer is no longer connected switch { // old session was never initiated case oldStats.BytesReceived == 0 && oldStats.BytesTransmitted == 0 && (newReceived > 0 || newTransmitted > 0): @@ -369,7 +369,7 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) { p.LastPing = nil } p.UpdatedAt = time.Now() - p.CalcConnected() + p.CalcConnected(c.cfg.Backend.ReKeyTimeoutInterval) if wasConnected != p.IsConnected { connectionStateChanged = true diff --git a/internal/app/wireguard/statistics_test.go b/internal/app/wireguard/statistics_test.go index dc1ba43..64fe395 100644 --- a/internal/app/wireguard/statistics_test.go +++ b/internal/app/wireguard/statistics_test.go @@ -5,10 +5,11 @@ import ( "testing" "time" + "github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/domain" ) -func Test_getSessionStartTime(t *testing.T) { +func TestStatisticsCollector_getSessionStartTime(t *testing.T) { now := time.Now() nowMinus1 := now.Add(-1 * time.Minute) nowMinus3 := now.Add(-3 * time.Minute) @@ -133,7 +134,14 @@ func Test_getSessionStartTime(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := getSessionStartTime(tt.args.oldStats, tt.args.newReceived, tt.args.newTransmitted, + c := &StatisticsCollector{ + cfg: &config.Config{ + Backend: config.Backend{ + ReKeyTimeoutInterval: 180 * time.Second, + }, + }, + } + if got := c.getSessionStartTime(tt.args.oldStats, tt.args.newReceived, tt.args.newTransmitted, tt.args.lastHandshake); !reflect.DeepEqual(got, tt.want) { t.Errorf("getSessionStartTime() = %v, want %v", got, tt.want) } diff --git a/internal/config/backend.go b/internal/config/backend.go index c02058f..5d54058 100644 --- a/internal/config/backend.go +++ b/internal/config/backend.go @@ -10,6 +10,8 @@ const LocalBackendName = "local" type Backend struct { Default string `yaml:"default"` // The default backend to use (defaults to the internal backend) + ReKeyTimeoutInterval time.Duration `yaml:"rekey_timeout_interval"` // Interval after which a connection is assumed dead + // Local Backend-specific configuration IgnoredLocalInterfaces []string `yaml:"ignored_local_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0") @@ -115,8 +117,8 @@ func (b *BackendMikrotik) GetApiTimeout() time.Duration { type BackendPfsense struct { BackendBase `yaml:",inline"` // Embed the base fields - ApiUrl string `yaml:"api_url"` // The base URL of the pfSense REST API (e.g., "https://pfsense.example.com/api/v2") - ApiKey string `yaml:"api_key"` // API key for authentication (generated in pfSense under 'System' -> 'REST API' -> 'Keys') + ApiUrl string `yaml:"api_url"` // The base URL of the pfSense REST API (e.g., "https://pfsense.example.com/api/v2") + ApiKey string `yaml:"api_key"` // API key for authentication (generated in pfSense under 'System' -> 'REST API' -> 'Keys') ApiVerifyTls bool `yaml:"api_verify_tls"` // Whether to verify the TLS certificate of the pfSense API ApiTimeout time.Duration `yaml:"api_timeout"` // Timeout for API requests (default: 30 seconds) diff --git a/internal/config/config.go b/internal/config/config.go index ee5be08..a88b2d1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -139,6 +139,7 @@ func defaultConfig() *Config { cfg.Backend = Backend{ Default: LocalBackendName, // local backend is the default (using wgcrtl) + ReKeyTimeoutInterval: getEnvDuration("WG_PORTAL_BACKEND_REKEY_TIMEOUT_INTERVAL", 180*time.Second), IgnoredLocalInterfaces: getEnvStrSlice("WG_PORTAL_BACKEND_IGNORED_LOCAL_INTERFACES", nil), // Most resolconf implementations use "tun." as a prefix for interface names. // But systemd's implementation uses no prefix, for example. diff --git a/internal/domain/statistics.go b/internal/domain/statistics.go index aa205e8..7070287 100644 --- a/internal/domain/statistics.go +++ b/internal/domain/statistics.go @@ -21,8 +21,8 @@ type PeerStatus struct { LastSessionStart *time.Time `gorm:"column:last_session_start" json:"LastSessionStart"` } -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 +func (s *PeerStatus) CalcConnected(timeout time.Duration) { + oldestHandshakeTime := time.Now().Add(-1 * timeout) // if a handshake is older than the rekey-interval + grace-period, the peer is no longer connected handshakeValid := false if s.LastHandshake != nil { diff --git a/internal/domain/statistics_test.go b/internal/domain/statistics_test.go index 1a43560..67aaed0 100644 --- a/internal/domain/statistics_test.go +++ b/internal/domain/statistics_test.go @@ -9,11 +9,16 @@ func TestPeerStatus_IsConnected(t *testing.T) { now := time.Now() past := now.Add(-3 * time.Minute) recent := now.Add(-1 * time.Minute) + defaultTimeout := 125 * time.Second // rekey interval of 120s + 5 seconds grace period + past126 := now.Add(-1*defaultTimeout - 1*time.Second) + past125 := now.Add(-1 * defaultTimeout) + past124 := now.Add(-1*defaultTimeout + 1*time.Second) tests := []struct { - name string - status PeerStatus - want bool + name string + status PeerStatus + timeout time.Duration + want bool }{ { name: "Pingable and recent handshake", @@ -21,7 +26,8 @@ func TestPeerStatus_IsConnected(t *testing.T) { IsPingable: true, LastHandshake: &recent, }, - want: true, + timeout: defaultTimeout, + want: true, }, { name: "Not pingable but recent handshake", @@ -29,7 +35,8 @@ func TestPeerStatus_IsConnected(t *testing.T) { IsPingable: false, LastHandshake: &recent, }, - want: true, + timeout: defaultTimeout, + want: true, }, { name: "Pingable but old handshake", @@ -37,15 +44,44 @@ func TestPeerStatus_IsConnected(t *testing.T) { IsPingable: true, LastHandshake: &past, }, - want: true, + timeout: defaultTimeout, + want: true, }, { - name: "Not pingable and old handshake", + name: "Not pingable and ok handshake (-124s)", + status: PeerStatus{ + IsPingable: false, + LastHandshake: &past124, + }, + timeout: defaultTimeout, + want: true, + }, + { + name: "Not pingable and old handshake (-125s)", + status: PeerStatus{ + IsPingable: false, + LastHandshake: &past125, + }, + timeout: defaultTimeout, + want: false, + }, + { + name: "Not pingable and old handshake (-126s)", + status: PeerStatus{ + IsPingable: false, + LastHandshake: &past126, + }, + timeout: defaultTimeout, + want: false, + }, + { + name: "Not pingable and old handshake (very old)", status: PeerStatus{ IsPingable: false, LastHandshake: &past, }, - want: false, + timeout: defaultTimeout, + want: false, }, { name: "Pingable and no handshake", @@ -53,7 +89,8 @@ func TestPeerStatus_IsConnected(t *testing.T) { IsPingable: true, LastHandshake: nil, }, - want: true, + timeout: defaultTimeout, + want: true, }, { name: "Not pingable and no handshake", @@ -61,12 +98,13 @@ func TestPeerStatus_IsConnected(t *testing.T) { IsPingable: false, LastHandshake: nil, }, - want: false, + timeout: defaultTimeout, + want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tt.status.CalcConnected() + tt.status.CalcConnected(tt.timeout) if got := tt.status.IsConnected; got != tt.want { t.Errorf("IsConnected = %v, want %v", got, tt.want) } From 402cc1b5f3ae3f105ccdfefe87e9f192a0b834cc Mon Sep 17 00:00:00 2001 From: Jacopo Clark <37738506+clark-ja@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:13:19 +0100 Subject: [PATCH 4/7] feat: Implement LDAP interface-specific provisioning filters (#642) * Implement LDAP filter-based access control for interface provisioning * test: add unit tests for LDAP interface filtering logic * smaller improvements / cleanup --------- Co-authored-by: jc <37738506+theguy147@users.noreply.github.com> Co-authored-by: Christoph Haas --- cmd/wg-portal/main.go | 2 +- docs/documentation/configuration/examples.md | 3 + docs/documentation/configuration/overview.md | 10 ++ docs/documentation/usage/authentication.md | 20 +++ docs/documentation/usage/user-sync.md | 10 +- frontend/src/stores/profile.js | 1 + frontend/src/views/ProfileView.vue | 11 +- internal/app/users/ldap_sync.go | 62 +++++++++ internal/app/users/user_manager.go | 28 ++-- internal/app/wireguard/wireguard.go | 1 + .../app/wireguard/wireguard_interfaces.go | 17 ++- .../wireguard/wireguard_interfaces_test.go | 124 ++++++++++++++++++ internal/app/wireguard/wireguard_peers.go | 33 +++++ .../app/wireguard/wireguard_peers_test.go | 4 + internal/config/auth.go | 4 + internal/domain/interface.go | 27 ++++ 16 files changed, 339 insertions(+), 18 deletions(-) diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go index 0592b82..327e327 100644 --- a/cmd/wg-portal/main.go +++ b/cmd/wg-portal/main.go @@ -80,7 +80,7 @@ func main() { internal.AssertNoError(err) auditRecorder.StartBackgroundJobs(ctx) - userManager, err := users.NewUserManager(cfg, eventBus, database, database) + userManager, err := users.NewUserManager(cfg, eventBus, database, database, database) internal.AssertNoError(err) userManager.StartBackgroundJobs(ctx) diff --git a/docs/documentation/configuration/examples.md b/docs/documentation/configuration/examples.md index bafc3df..7dfdb33 100644 --- a/docs/documentation/configuration/examples.md +++ b/docs/documentation/configuration/examples.md @@ -86,6 +86,9 @@ auth: memberof: memberOf admin_group: CN=WireGuardAdmins,OU=Some-OU,DC=COMPANY,DC=LOCAL registration_enabled: true + # Restrict interface access based on LDAP filters + interface_filter: + wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)" log_user_info: true ``` diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index cad55c6..9fa1f84 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -742,6 +742,16 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`: - **Important**: The `login_filter` must always be a valid LDAP filter. It should at most return one user. If the filter returns multiple or no users, the login will fail. +#### `interface_filter` +- **Default:** *(empty)* +- **Description:** A map of LDAP filters to restrict access to specific WireGuard interfaces. The map keys are the interface identifiers (e.g., `wg0`), and the values are LDAP filters. Only users matching the filter will be allowed to provision peers for the respective interface. + For example: + ```yaml + interface_filter: + wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)" + wg1: "(description=special-access)" + ``` + #### `admin_group` - **Default:** *(empty)* - **Description:** A specific LDAP group whose members are considered administrators in WireGuard Portal. diff --git a/docs/documentation/usage/authentication.md b/docs/documentation/usage/authentication.md index 76a9d67..d02afeb 100644 --- a/docs/documentation/usage/authentication.md +++ b/docs/documentation/usage/authentication.md @@ -147,6 +147,26 @@ You can map users to admin roles based on their group membership in the LDAP ser The `admin_group` property defines the distinguished name of the group that is allowed to log in as admin. All groups that are listed in the `memberof` attribute of the user will be checked against this group. If one of the groups matches, the user is granted admin access. +### Interface-specific Provisioning Filters + +You can restrict which users are allowed to provision peers for specific WireGuard interfaces by setting the `interface_filter` property. +This property is a map where each key corresponds to a WireGuard interface identifier, and the value is an LDAP filter. +A user will only be able to see and provision peers for an interface if they match the specified LDAP filter for that interface. + +Example: +```yaml +auth: + ldap: + - provider_name: "ldap1" + # ... other settings + interface_filter: + wg0: "(memberOf=CN=VPNUsers,OU=Groups,DC=COMPANY,DC=LOCAL)" + wg1: "(department=IT)" +``` + +This feature works by materializing the list of authorized users for each interface during the periodic LDAP synchronization. +Even if a user bypasses the UI, the backend will enforce these restrictions at the service layer. + ## User Synchronization diff --git a/docs/documentation/usage/user-sync.md b/docs/documentation/usage/user-sync.md index b248ffb..e7c4e2a 100644 --- a/docs/documentation/usage/user-sync.md +++ b/docs/documentation/usage/user-sync.md @@ -43,4 +43,12 @@ If you set the `disable_missing` property to `true`, any user that is not found All peers associated with that user will also be disabled. If you want a user and its peers to be automatically re-enabled once they are found in LDAP again, set the `auto_re_enable` property to `true`. -This will only re-enable the user if they were disabled by the synchronization process. Manually disabled users will not be re-enabled. \ No newline at end of file +This will only re-enable the user if they were disabled by the synchronization process. Manually disabled users will not be re-enabled. + +##### Interface-specific Access Materialization + +If `interface_filter` is configured in the LDAP provider, the synchronization process will evaluate these filters for each enabled user. +The results are materialized in the `interfaces` table of the database in a hidden field. +This materialized list is used by the backend to quickly determine if a user has permission to provision peers for a specific interface, without having to query the LDAP server for every request. +The list is refreshed every time the LDAP synchronization runs. +For more details on how to configure these filters, see the [Authentication](./authentication.md#interface-specific-provisioning-filters) section. \ No newline at end of file diff --git a/frontend/src/stores/profile.js b/frontend/src/stores/profile.js index 8b5df8f..632e931 100644 --- a/frontend/src/stores/profile.js +++ b/frontend/src/stores/profile.js @@ -74,6 +74,7 @@ export const profileStore = defineStore('profile', { }, hasStatistics: (state) => state.statsEnabled, CountInterfaces: (state) => state.interfaces.length, + HasInterface: (state) => (id) => state.interfaces.some((i) => i.Identifier === id), }, actions: { afterPageSizeChange() { diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue index 8f7b3e5..6c91c6c 100644 --- a/frontend/src/views/ProfileView.vue +++ b/frontend/src/views/ProfileView.vue @@ -80,6 +80,8 @@ onMounted(async () => {

{{ $t('profile.headline') }}

+
+
@@ -90,8 +92,8 @@ onMounted(async () => {
-
-
+
+