Improved default peer handling (#674)
Some checks are pending
Docker / Build and Push (push) Waiting to run
Docker / release (push) Blocked by required conditions
github-pages / deploy (push) Waiting to run

* create default peers for newly created interfaces (#666)

* allow to manually create default peers for an interface (#666)
This commit is contained in:
h44z
2026-04-16 21:55:41 +02:00
committed by GitHub
parent 51e4c0ebf1
commit 1c133b6f6e
31 changed files with 658 additions and 336 deletions

View File

@@ -2,6 +2,7 @@ package backend
import (
"context"
"fmt"
"io"
"github.com/h44z/wg-portal/internal/config"
@@ -18,6 +19,7 @@ type InterfaceServiceInterfaceManager interface {
DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error
PrepareInterface(ctx context.Context) (*domain.Interface, error)
ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error
CreateDefaultPeers(ctx context.Context, id domain.InterfaceIdentifier) error
}
type InterfaceServiceConfigFileManager interface {
@@ -89,3 +91,10 @@ func (i InterfaceService) PersistInterfaceConfig(ctx context.Context, id domain.
func (i InterfaceService) ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error {
return i.interfaces.ApplyPeerDefaults(ctx, in)
}
func (i InterfaceService) CreateDefaultPeers(ctx context.Context, id domain.InterfaceIdentifier) error {
if !i.cfg.DefaultPeerCreationEnabled() {
return fmt.Errorf("default peer creation is not enabled")
}
return i.interfaces.CreateDefaultPeers(ctx, id)
}

View File

@@ -145,7 +145,7 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
MinPasswordLength: e.cfg.Auth.MinPasswordLength,
AvailableBackends: controllerFn(),
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
CreateDefaultPeer: e.cfg.Core.CreateDefaultPeer,
CreateDefaultPeer: e.cfg.DefaultPeerCreationEnabled(),
})
}
}

View File

@@ -33,6 +33,8 @@ type InterfaceService interface {
PersistInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) error
// ApplyPeerDefaults applies the peer defaults to all peers of the given interface.
ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error
// CreateDefaultPeers creates default peers for all existing users on the given interface.
CreateDefaultPeers(ctx context.Context, id domain.InterfaceIdentifier) error
}
type InterfaceEndpoint struct {
@@ -73,6 +75,7 @@ func (e InterfaceEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup.HandleFunc("GET /config/{id}", e.handleConfigGet())
apiGroup.HandleFunc("POST /{id}/save-config", e.handleSaveConfigPost())
apiGroup.HandleFunc("POST /{id}/apply-peer-defaults", e.handleApplyPeerDefaultsPost())
apiGroup.HandleFunc("POST /{id}/create-default-peers", e.handleCreateDefaultPeersPost())
apiGroup.HandleFunc("GET /peers/{id}", e.handlePeersGet())
}
@@ -421,3 +424,34 @@ func (e InterfaceEndpoint) handleApplyPeerDefaultsPost() http.HandlerFunc {
respond.Status(w, http.StatusNoContent)
}
}
// handleCreateDefaultPeersPost returns a gorm Handler function.
//
// @ID interfaces_handleCreateDefaultPeersPost
// @Tags Interface
// @Summary Create default peers for all existing users on the given interface.
// @Produce json
// @Param id path string true "The interface identifier"
// @Success 204 "No content if creating the default peers was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /interface/{id}/create-default-peers [post]
func (e InterfaceEndpoint) handleCreateDefaultPeersPost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := Base64UrlDecode(request.Path(r, "id"))
if id == "" {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
return
}
if err := e.interfaceService.CreateDefaultPeers(r.Context(), domain.InterfaceIdentifier(id)); err != nil {
respond.JSON(w, http.StatusInternalServerError, model.Error{
Code: http.StatusInternalServerError, Message: err.Error(),
})
return
}
respond.Status(w, http.StatusNoContent)
}
}

View File

@@ -36,6 +36,7 @@ type InterfaceAndPeerDatabaseRepo interface {
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)
GetAllUsers(ctx context.Context) ([]domain.User, error)
}
type WgQuickController interface {
@@ -59,7 +60,8 @@ type Manager struct {
db InterfaceAndPeerDatabaseRepo
wg *ControllerManager
userLockMap *sync.Map
userLockMap *sync.Map
interfaceLockMap *sync.Map
}
func NewWireGuardManager(
@@ -69,11 +71,12 @@ func NewWireGuardManager(
db InterfaceAndPeerDatabaseRepo,
) (*Manager, error) {
m := &Manager{
cfg: cfg,
bus: bus,
wg: wg,
db: db,
userLockMap: &sync.Map{},
cfg: cfg,
bus: bus,
wg: wg,
db: db,
userLockMap: &sync.Map{},
interfaceLockMap: &sync.Map{},
}
m.connectToMessageBus()
@@ -93,10 +96,11 @@ func (m Manager) connectToMessageBus() {
_ = m.bus.Subscribe(app.TopicUserDisabled, m.handleUserDisabledEvent)
_ = m.bus.Subscribe(app.TopicUserEnabled, m.handleUserEnabledEvent)
_ = m.bus.Subscribe(app.TopicUserDeleted, m.handleUserDeletedEvent)
_ = m.bus.Subscribe(app.TopicInterfaceCreated, m.handleInterfaceCreatedEvent)
}
func (m Manager) handleUserCreationEvent(user domain.User) {
if !m.cfg.Core.CreateDefaultPeerOnCreation {
if !m.cfg.Core.CreateDefaultPeerOnUserCreation {
return
}
@@ -117,7 +121,7 @@ func (m Manager) handleUserCreationEvent(user domain.User) {
}
func (m Manager) handleUserLoginEvent(userId domain.UserIdentifier) {
if !m.cfg.Core.CreateDefaultPeer {
if !m.cfg.Core.CreateDefaultPeerOnLogin {
return
}
@@ -269,6 +273,31 @@ func (m Manager) handleUserDeletedEvent(user domain.User) {
}
}
// handleInterfaceCreatedEvent creates default peers for all existing users when a new interface is created.
// This ensures users that already exist (e.g. imported via a prior LDAP sync that had no interface available)
// also receive a default peer for the newly created interface.
func (m Manager) handleInterfaceCreatedEvent(iface domain.Interface) {
if !m.cfg.Core.CreateDefaultPeerOnUserCreation {
return
}
_, loaded := m.interfaceLockMap.LoadOrStore(iface.Identifier, "create")
if loaded {
return // another goroutine is already handling this interface
}
defer m.interfaceLockMap.Delete(iface.Identifier)
slog.Debug("handling new interface event", "interface", iface.Identifier)
ctx := domain.SetUserInfo(context.Background(), domain.SystemAdminContextUserInfo())
err := m.CreateDefaultPeers(ctx, iface.Identifier)
if err != nil {
slog.Error("failed to create default peers on new interface",
"interface", iface.Identifier, "error", err)
}
}
func (m Manager) runExpiredPeersCheck(ctx context.Context) {
ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo())

View File

@@ -387,7 +387,7 @@ func (m Manager) PrepareInterface(ctx context.Context) (*domain.Interface, error
SaveConfig: m.cfg.Advanced.ConfigStoragePath != "",
DisplayName: string(id),
Type: domain.InterfaceTypeServer,
CreateDefaultPeer: m.cfg.Core.CreateDefaultPeer,
CreateDefaultPeer: m.cfg.DefaultPeerCreationEnabled(),
DriverType: "",
Disabled: nil,
DisabledReason: "",

View File

@@ -94,13 +94,6 @@ 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{

View File

@@ -15,6 +15,10 @@ import (
// CreateDefaultPeer creates a default peer for the given user on all server interfaces.
func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdentifier) error {
if !m.cfg.DefaultPeerCreationEnabled() {
return nil
}
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return err
}
@@ -24,39 +28,21 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti
return fmt.Errorf("failed to fetch all interfaces: %w", err)
}
userPeers, err := m.db.GetUserPeers(context.Background(), userId)
user, err := m.db.GetUser(ctx, userId)
if err != nil {
return fmt.Errorf("failed to retrieve existing peers prior to default peer creation: %w", err)
return fmt.Errorf("failed to fetch user: %w", err)
}
var newPeers []domain.Peer
for _, iface := range existingInterfaces {
if iface.Type != domain.InterfaceTypeServer {
continue // only create default peers for server interfaces
}
if !iface.CreateDefaultPeer {
continue // only create default peers if the interface flag is set
}
peerAlreadyCreated := slices.ContainsFunc(userPeers, func(peer domain.Peer) bool {
return peer.InterfaceIdentifier == iface.Identifier
})
if peerAlreadyCreated {
continue // skip creation if a peer already exists for this interface
}
peer, err := m.PreparePeer(ctx, iface.Identifier)
peer, err := m.prepareDefaultPeer(ctx, &iface, user)
if err != nil {
return fmt.Errorf("failed to create default peer for interface %s: %w", iface.Identifier, err)
return fmt.Errorf("failed to prepare default peer: %w", err)
}
peer.UserIdentifier = userId
peer.Notes = fmt.Sprintf("Default peer created for user %s", userId)
peer.AutomaticallyCreated = true
peer.GenerateDisplayName("Default")
newPeers = append(newPeers, *peer)
if peer != nil {
newPeers = append(newPeers, *peer)
}
}
for i, peer := range newPeers {
@@ -67,9 +53,61 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti
}
}
slog.InfoContext(ctx, "created default peers for user",
"user", userId,
"count", len(newPeers))
slog.InfoContext(ctx, "created default peers for user", "user", userId, "count", len(newPeers))
return nil
}
// CreateDefaultPeers creates default peers for all existing users on the given interface.
func (m Manager) CreateDefaultPeers(ctx context.Context, interfaceId domain.InterfaceIdentifier) error {
if !m.cfg.DefaultPeerCreationEnabled() {
return nil
}
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return err
}
iface, err := m.db.GetInterface(ctx, interfaceId)
if err != nil {
return fmt.Errorf("failed to fetch interface %s: %w", interfaceId, err)
}
if !iface.CreateDefaultPeers() {
return nil
}
users, err := m.db.GetAllUsers(ctx)
if err != nil {
return fmt.Errorf("failed to fetch all users: %w", err)
}
var errs error
var peerCount int
for _, user := range users {
peer, err := m.prepareDefaultPeer(ctx, iface, &user)
if err != nil {
errs = errors.Join(errs, fmt.Errorf("failed to prepare default peer for user %s: %w",
user.Identifier, err))
continue
}
if peer == nil {
continue
}
_, err = m.CreatePeer(ctx, peer)
if err != nil {
errs = errors.Join(errs, fmt.Errorf("failed to create default peer for user %s: %w",
user.Identifier, err))
continue
}
peerCount++
}
if errs != nil {
return fmt.Errorf("failed to create default peers for interface %s: %w", interfaceId, errs)
}
slog.InfoContext(ctx, "created default peers for interface", "interface", interfaceId, "count", peerCount)
return nil
}
@@ -639,4 +677,39 @@ func (m Manager) checkInterfaceAccess(ctx context.Context, id domain.InterfaceId
return nil
}
func (m Manager) prepareDefaultPeer(ctx context.Context, iface *domain.Interface, user *domain.User) (
*domain.Peer,
error,
) {
if !iface.CreateDefaultPeers() || !user.CreateDefaultPeers() {
return nil, nil
}
userPeers, err := m.db.GetUserPeers(ctx, user.Identifier)
if err != nil {
return nil, fmt.Errorf("failed to retrieve existing peers prior to default peer creation: %w", err)
}
peerAlreadyCreated := slices.ContainsFunc(userPeers, func(peer domain.Peer) bool {
// Ignore the AutomaticallyCreated flag on the peer.
// If a user already has a peer for a given interface, no default peer should be created.
return peer.InterfaceIdentifier == iface.Identifier
})
if peerAlreadyCreated {
return nil, nil // skip creation if a peer already exists for this interface
}
peer, err := m.PreparePeer(ctx, iface.Identifier)
if err != nil {
return nil, fmt.Errorf("failed to create default peer for interface %s: %w", iface.Identifier, err)
}
peer.UserIdentifier = user.Identifier
peer.Notes = fmt.Sprintf("Default peer created for user %s", user.Identifier)
peer.AutomaticallyCreated = true
peer.GenerateDisplayName("Default")
return peer, nil
}
// endregion helper-functions

View File

@@ -61,6 +61,7 @@ type mockDB struct {
savedPeers map[domain.PeerIdentifier]*domain.Peer
iface *domain.Interface
interfaces []domain.Interface
users []domain.User
}
func (f *mockDB) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error) {
@@ -141,6 +142,15 @@ func (f *mockDB) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr)
) {
return map[domain.Cidr][]domain.Cidr{}, nil
}
func (f *mockDB) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
return &domain.User{
Identifier: id,
IsAdmin: false,
}, nil
}
func (f *mockDB) GetAllUsers(ctx context.Context) ([]domain.User, error) {
return f.users, nil
}
// --- Test ---
@@ -205,7 +215,7 @@ func TestCreatePeer_SetsIdentifier_FromPublicKey(t *testing.T) {
func TestCreateDefaultPeer_RespectsInterfaceFlag(t *testing.T) {
// Arrange
cfg := &config.Config{}
cfg.Core.CreateDefaultPeer = true
cfg.Core.CreateDefaultPeerOnLogin = true
bus := &mockBus{}
ctrlMgr := &ControllerManager{