@@ -174,7 +175,7 @@ onMounted(async () => {
-
|
diff --git a/go.mod b/go.mod
index 873f377..914ab7e 100644
--- a/go.mod
+++ b/go.mod
@@ -7,10 +7,10 @@ require (
github.com/alexedwards/scs/v2 v2.9.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/glebarez/sqlite v1.11.0
- github.com/go-ldap/ldap/v3 v3.4.12
+ github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-pkgz/routegroup v1.6.0
github.com/go-playground/validator/v10 v10.30.1
- github.com/go-webauthn/webauthn v0.16.0
+ github.com/go-webauthn/webauthn v0.16.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/prometheus-community/pro-bing v0.8.0
@@ -22,9 +22,9 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0
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/sys v0.41.0
+ golang.org/x/crypto v0.49.0
+ golang.org/x/oauth2 v0.36.0
+ golang.org/x/sys v0.42.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
@@ -61,7 +61,7 @@ require (
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-test/deep v1.1.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
- github.com/go-webauthn/x v0.2.1 // indirect
+ github.com/go-webauthn/x v0.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
@@ -95,9 +95,9 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/mod v0.33.0 // indirect
- golang.org/x/net v0.50.0 // indirect
- golang.org/x/sync v0.19.0 // indirect
- golang.org/x/text v0.34.0 // indirect
+ golang.org/x/net v0.51.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/protobuf v1.36.11 // indirect
diff --git a/go.sum b/go.sum
index 68994fc..181f51d 100644
--- a/go.sum
+++ b/go.sum
@@ -60,8 +60,8 @@ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
-github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
-github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
+github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
+github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
@@ -105,10 +105,10 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
-github.com/go-webauthn/webauthn v0.16.0 h1:A9BkfYIwWAMPSQCbM2HoWqo6JO5LFI8aqYAzo6nW7AY=
-github.com/go-webauthn/webauthn v0.16.0/go.mod h1:hm9RS/JNYeUu3KqGbzqlnHClhDGCZzTZlABjathwnN0=
-github.com/go-webauthn/x v0.2.1 h1:/oB8i0FhSANuoN+YJF5XHMtppa7zGEYaQrrf6ytotjc=
-github.com/go-webauthn/x v0.2.1/go.mod h1:Wm0X0zXkzznit4gHj4m82GiBZRMEm+TDUIoJWIQLsE4=
+github.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU=
+github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ=
+github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=
+github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
@@ -274,8 +274,8 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
-golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
-golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -303,10 +303,10 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
-golang.org/x/net v0.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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
+golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
+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=
@@ -314,8 +314,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -336,8 +336,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -366,8 +366,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
-golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
-golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
diff --git a/internal/adapters/metrics.go b/internal/adapters/metrics.go
index 6b9a4b7..db90796 100644
--- a/internal/adapters/metrics.go
+++ b/internal/adapters/metrics.go
@@ -30,7 +30,7 @@ type MetricsServer struct {
// Wireguard metrics labels
var (
ifaceLabels = []string{"interface"}
- peerLabels = []string{"interface", "addresses", "id", "name"}
+ peerLabels = []string{"interface", "addresses", "id", "name", "user"}
)
// NewMetricsServer returns a new prometheus server
@@ -126,6 +126,7 @@ func (m *MetricsServer) UpdatePeerMetrics(peer *domain.Peer, status domain.PeerS
peer.Interface.AddressStr(),
string(status.PeerId),
peer.DisplayName,
+ string(peer.UserIdentifier),
}
if status.LastHandshake != nil {
diff --git a/internal/app/users/ldap_sync.go b/internal/app/users/ldap_sync.go
index f3215cc..cc0fff9 100644
--- a/internal/app/users/ldap_sync.go
+++ b/internal/app/users/ldap_sync.go
@@ -90,6 +90,12 @@ func (m Manager) synchronizeLdapUsers(ctx context.Context, provider *config.Ldap
}
}
+ // Update interface allowed users based on LDAP filters
+ err = m.updateInterfaceLdapFilters(ctx, conn, provider)
+ if err != nil {
+ return err
+ }
+
return nil
}
@@ -237,3 +243,59 @@ func (m Manager) disableMissingLdapUsers(
return nil
}
+
+func (m Manager) updateInterfaceLdapFilters(
+ ctx context.Context,
+ conn *ldap.Conn,
+ provider *config.LdapProvider,
+) error {
+ if len(provider.InterfaceFilter) == 0 {
+ return nil // nothing to do if no interfaces are configured for this provider
+ }
+
+ for ifaceName, groupFilter := range provider.InterfaceFilter {
+ ifaceId := domain.InterfaceIdentifier(ifaceName)
+
+ // Combined filter: user must match the provider's base SyncFilter AND the interface's LdapGroupFilter
+ combinedFilter := fmt.Sprintf("(&(%s)(%s))", provider.SyncFilter, groupFilter)
+
+ rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, combinedFilter, &provider.FieldMap)
+ if err != nil {
+ slog.Error("failed to find users for interface filter",
+ "interface", ifaceId,
+ "provider", provider.ProviderName,
+ "error", err)
+ continue
+ }
+
+ matchedUserIds := make([]domain.UserIdentifier, 0, len(rawUsers))
+ for _, rawUser := range rawUsers {
+ userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, provider.FieldMap.UserIdentifier, ""))
+ if userId != "" {
+ matchedUserIds = append(matchedUserIds, userId)
+ }
+ }
+
+ // Save the interface
+ err = m.interfaces.SaveInterface(ctx, ifaceId, func(i *domain.Interface) (*domain.Interface, error) {
+ if i.LdapAllowedUsers == nil {
+ i.LdapAllowedUsers = make(map[string][]domain.UserIdentifier)
+ }
+ i.LdapAllowedUsers[provider.ProviderName] = matchedUserIds
+ return i, nil
+ })
+ if err != nil {
+ slog.Error("failed to save interface ldap allowed users",
+ "interface", ifaceId,
+ "provider", provider.ProviderName,
+ "error", err)
+ } else {
+ slog.Debug("updated interface ldap allowed users",
+ "interface", ifaceId,
+ "provider", provider.ProviderName,
+ "matched_count", len(matchedUserIds))
+ }
+ }
+
+ return nil
+}
diff --git a/internal/app/users/user_manager.go b/internal/app/users/user_manager.go
index a3aae07..447fc9d 100644
--- a/internal/app/users/user_manager.go
+++ b/internal/app/users/user_manager.go
@@ -39,6 +39,11 @@ type PeerDatabaseRepo interface {
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
}
+type InterfaceDatabaseRepo interface {
+ // SaveInterface saves the interface with the given identifier.
+ SaveInterface(ctx context.Context, id domain.InterfaceIdentifier, updateFunc func(i *domain.Interface) (*domain.Interface, error)) error
+}
+
type EventBus interface {
// Publish sends a message to the message bus.
Publish(topic string, args ...any)
@@ -50,22 +55,27 @@ type EventBus interface {
type Manager struct {
cfg *config.Config
- bus EventBus
- users UserDatabaseRepo
- peers PeerDatabaseRepo
+ bus EventBus
+ users UserDatabaseRepo
+ peers PeerDatabaseRepo
+ interfaces InterfaceDatabaseRepo
}
// NewUserManager creates a new user manager instance.
-func NewUserManager(cfg *config.Config, bus EventBus, users UserDatabaseRepo, peers PeerDatabaseRepo) (
- *Manager,
- error,
-) {
+func NewUserManager(
+ cfg *config.Config,
+ bus EventBus,
+ users UserDatabaseRepo,
+ peers PeerDatabaseRepo,
+ interfaces InterfaceDatabaseRepo,
+) (*Manager, error) {
m := &Manager{
cfg: cfg,
bus: bus,
- users: users,
- peers: peers,
+ users: users,
+ peers: peers,
+ interfaces: interfaces,
}
return m, nil
}
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/app/wireguard/wireguard.go b/internal/app/wireguard/wireguard.go
index e1e9dfa..c564240 100644
--- a/internal/app/wireguard/wireguard.go
+++ b/internal/app/wireguard/wireguard.go
@@ -35,6 +35,7 @@ type InterfaceAndPeerDatabaseRepo interface {
DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error)
GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr) (map[domain.Cidr][]domain.Cidr, error)
+ GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
}
type WgQuickController interface {
diff --git a/internal/app/wireguard/wireguard_interfaces.go b/internal/app/wireguard/wireguard_interfaces.go
index 77f6f96..455c2fd 100644
--- a/internal/app/wireguard/wireguard_interfaces.go
+++ b/internal/app/wireguard/wireguard_interfaces.go
@@ -16,6 +16,11 @@ import (
"github.com/h44z/wg-portal/internal/domain"
)
+// GetInterface returns the interface for the given interface identifier.
+func (m Manager) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
+ return m.db.GetInterface(ctx, id)
+}
+
// GetInterfaceAndPeers returns the interface and all peers for the given interface identifier.
func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (
*domain.Interface,
@@ -63,12 +68,17 @@ func (m Manager) GetAllInterfacesAndPeers(ctx context.Context) ([]domain.Interfa
// GetUserInterfaces returns all interfaces that are available for users to create new peers.
// If self-provisioning is disabled, this function will return an empty list.
-// At the moment, there are no interfaces specific to single users, thus the user id is not used.
-func (m Manager) GetUserInterfaces(ctx context.Context, _ domain.UserIdentifier) ([]domain.Interface, error) {
+func (m Manager) GetUserInterfaces(ctx context.Context, userId domain.UserIdentifier) ([]domain.Interface, error) {
if !m.cfg.Core.SelfProvisioningAllowed {
return nil, nil // self-provisioning is disabled - no interfaces for users
}
+ user, err := m.db.GetUser(ctx, userId)
+ if err != nil {
+ slog.Error("failed to load user for interface group verification", "user", userId, "error", err)
+ return nil, nil // fail closed
+ }
+
interfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return nil, fmt.Errorf("unable to load all interfaces: %w", err)
@@ -83,6 +93,9 @@ func (m Manager) GetUserInterfaces(ctx context.Context, _ domain.UserIdentifier)
if iface.Type != domain.InterfaceTypeServer {
continue // skip client interfaces
}
+ if !user.IsAdmin && !iface.IsUserAllowed(userId, m.cfg) {
+ continue // user not allowed due to LDAP group filter
+ }
userInterfaces = append(userInterfaces, iface.PublicInfo())
}
diff --git a/internal/app/wireguard/wireguard_interfaces_test.go b/internal/app/wireguard/wireguard_interfaces_test.go
index 95f202b..80e56e0 100644
--- a/internal/app/wireguard/wireguard_interfaces_test.go
+++ b/internal/app/wireguard/wireguard_interfaces_test.go
@@ -6,6 +6,7 @@ import (
"github.com/stretchr/testify/assert"
+ "github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
@@ -92,3 +93,126 @@ func TestImportPeer_AddressMapping(t *testing.T) {
})
}
}
+
+func (f *mockDB) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
+ return &domain.User{
+ Identifier: id,
+ IsAdmin: false,
+ }, nil
+}
+
+func TestInterface_IsUserAllowed(t *testing.T) {
+ cfg := &config.Config{
+ Auth: config.Auth{
+ Ldap: []config.LdapProvider{
+ {
+ ProviderName: "ldap1",
+ InterfaceFilter: map[string]string{
+ "wg0": "(memberOf=CN=VPNUsers,...)",
+ },
+ },
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ iface domain.Interface
+ userId domain.UserIdentifier
+ expect bool
+ }{
+ {
+ name: "Unrestricted interface",
+ iface: domain.Interface{
+ Identifier: "wg1",
+ },
+ userId: "user1",
+ expect: true,
+ },
+ {
+ name: "Restricted interface - user allowed",
+ iface: domain.Interface{
+ Identifier: "wg0",
+ LdapAllowedUsers: map[string][]domain.UserIdentifier{
+ "ldap1": {"user1"},
+ },
+ },
+ userId: "user1",
+ expect: true,
+ },
+ {
+ name: "Restricted interface - user allowed (at least one match)",
+ iface: domain.Interface{
+ Identifier: "wg0",
+ LdapAllowedUsers: map[string][]domain.UserIdentifier{
+ "ldap1": {"user2"},
+ "ldap2": {"user1"},
+ },
+ },
+ userId: "user1",
+ expect: true,
+ },
+ {
+ name: "Restricted interface - user NOT allowed",
+ iface: domain.Interface{
+ Identifier: "wg0",
+ LdapAllowedUsers: map[string][]domain.UserIdentifier{
+ "ldap1": {"user2"},
+ },
+ },
+ userId: "user1",
+ expect: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.expect, tt.iface.IsUserAllowed(tt.userId, cfg))
+ })
+ }
+}
+
+func TestManager_GetUserInterfaces_Filtering(t *testing.T) {
+ cfg := &config.Config{}
+ cfg.Core.SelfProvisioningAllowed = true
+ cfg.Auth.Ldap = []config.LdapProvider{
+ {
+ ProviderName: "ldap1",
+ InterfaceFilter: map[string]string{
+ "wg_restricted": "(some-filter)",
+ },
+ },
+ }
+
+ db := &mockDB{
+ interfaces: []domain.Interface{
+ {Identifier: "wg_public", Type: domain.InterfaceTypeServer},
+ {
+ Identifier: "wg_restricted",
+ Type: domain.InterfaceTypeServer,
+ LdapAllowedUsers: map[string][]domain.UserIdentifier{
+ "ldap1": {"allowed_user"},
+ },
+ },
+ },
+ }
+ m := Manager{
+ cfg: cfg,
+ db: db,
+ }
+
+ t.Run("Allowed user sees both", func(t *testing.T) {
+ ifaces, err := m.GetUserInterfaces(context.Background(), "allowed_user")
+ assert.NoError(t, err)
+ assert.Equal(t, 2, len(ifaces))
+ })
+
+ t.Run("Unallowed user sees only public", func(t *testing.T) {
+ ifaces, err := m.GetUserInterfaces(context.Background(), "other_user")
+ assert.NoError(t, err)
+ assert.Equal(t, 1, len(ifaces))
+ if len(ifaces) > 0 {
+ assert.Equal(t, domain.InterfaceIdentifier("wg_public"), ifaces[0].Identifier)
+ }
+ })
+}
diff --git a/internal/app/wireguard/wireguard_peers.go b/internal/app/wireguard/wireguard_peers.go
index 46dfef3..4232942 100644
--- a/internal/app/wireguard/wireguard_peers.go
+++ b/internal/app/wireguard/wireguard_peers.go
@@ -93,6 +93,10 @@ func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier)
currentUser := domain.GetUserInfo(ctx)
+ if err := m.checkInterfaceAccess(ctx, id); err != nil {
+ return nil, err
+ }
+
iface, err := m.db.GetInterface(ctx, id)
if err != nil {
return nil, fmt.Errorf("unable to find interface %s: %w", id, err)
@@ -188,6 +192,9 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
if err := domain.ValidateUserAccessRights(ctx, peer.UserIdentifier); err != nil {
return nil, err
}
+ if err := m.checkInterfaceAccess(ctx, peer.InterfaceIdentifier); err != nil {
+ return nil, err
+ }
}
sessionUser := domain.GetUserInfo(ctx)
@@ -304,6 +311,10 @@ func (m Manager) UpdatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
return nil, err
}
+ if err := m.checkInterfaceAccess(ctx, existingPeer.InterfaceIdentifier); err != nil {
+ return nil, err
+ }
+
if err := m.validatePeerModifications(ctx, existingPeer, peer); err != nil {
return nil, fmt.Errorf("update not allowed: %w", err)
}
@@ -373,6 +384,10 @@ func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error
return err
}
+ if err := m.checkInterfaceAccess(ctx, peer.InterfaceIdentifier); err != nil {
+ return err
+ }
+
if err := m.validatePeerDeletion(ctx, peer); err != nil {
return fmt.Errorf("delete not allowed: %w", err)
}
@@ -606,4 +621,22 @@ func (m Manager) validatePeerDeletion(ctx context.Context, _ *domain.Peer) error
return nil
}
+func (m Manager) checkInterfaceAccess(ctx context.Context, id domain.InterfaceIdentifier) error {
+ user := domain.GetUserInfo(ctx)
+ if user.IsAdmin {
+ return nil
+ }
+
+ iface, err := m.db.GetInterface(ctx, id)
+ if err != nil {
+ return fmt.Errorf("failed to get interface %s: %w", id, err)
+ }
+
+ if !iface.IsUserAllowed(user.Id, m.cfg) {
+ return fmt.Errorf("user %s is not allowed to access interface %s: %w", user.Id, id, domain.ErrNoPermission)
+ }
+
+ return nil
+}
+
// endregion helper-functions
diff --git a/internal/app/wireguard/wireguard_peers_test.go b/internal/app/wireguard/wireguard_peers_test.go
index 8a08122..8ebb4b4 100644
--- a/internal/app/wireguard/wireguard_peers_test.go
+++ b/internal/app/wireguard/wireguard_peers_test.go
@@ -60,6 +60,7 @@ func (f *mockController) PingAddresses(_ context.Context, _ string) (*domain.Pin
type mockDB struct {
savedPeers map[domain.PeerIdentifier]*domain.Peer
iface *domain.Interface
+ interfaces []domain.Interface
}
func (f *mockDB) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
@@ -79,6 +80,9 @@ func (f *mockDB) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier
return nil, nil
}
func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
+ if f.interfaces != nil {
+ return f.interfaces, nil
+ }
if f.iface != nil {
return []domain.Interface{*f.iface}, nil
}
diff --git a/internal/config/auth.go b/internal/config/auth.go
index 4314b63..a3c6f5a 100644
--- a/internal/config/auth.go
+++ b/internal/config/auth.go
@@ -214,6 +214,10 @@ type LdapProvider struct {
// If RegistrationEnabled is set to true, wg-portal will create new users that do not exist in the database.
RegistrationEnabled bool `yaml:"registration_enabled"`
+ // InterfaceFilter allows restricting interfaces using an LDAP filter.
+ // Map key is the interface identifier (e.g., "wg0"), value is the filter string.
+ InterfaceFilter map[string]string `yaml:"interface_filter"`
+
// If LogUserInfo is set to true, the user info retrieved from the LDAP provider will be logged in trace level.
LogUserInfo bool `yaml:"log_user_info"`
}
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/interface.go b/internal/domain/interface.go
index d8e4f0a..2efd5bf 100644
--- a/internal/domain/interface.go
+++ b/internal/domain/interface.go
@@ -78,6 +78,33 @@ type Interface struct {
PeerDefPostUp string // default action that is executed after the device is up
PeerDefPreDown string // default action that is executed before the device is down
PeerDefPostDown string // default action that is executed after the device is down
+
+ // Self-provisioning access control
+ LdapAllowedUsers map[string][]UserIdentifier `gorm:"serializer:json"` // Materialised during LDAP sync, keyed by ProviderName
+}
+
+// IsUserAllowed returns true if the interface has no filter, or if the user is in the allowed list.
+func (i *Interface) IsUserAllowed(userId UserIdentifier, cfg *config.Config) bool {
+ isRestricted := false
+ for _, provider := range cfg.Auth.Ldap {
+ if _, exists := provider.InterfaceFilter[string(i.Identifier)]; exists {
+ isRestricted = true
+ break
+ }
+ }
+
+ if !isRestricted {
+ return true // The interface is completely unrestricted by LDAP config
+ }
+
+ for _, allowedUsers := range i.LdapAllowedUsers {
+ for _, uid := range allowedUsers {
+ if uid == userId {
+ return true
+ }
+ }
+ }
+ return false
}
// PublicInfo returns a copy of the interface with only the public information.
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)
}