diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go
index da1b06c..83512ec 100644
--- a/cmd/wg-portal/main.go
+++ b/cmd/wg-portal/main.go
@@ -47,7 +47,7 @@ func main() {
rawDb, err := adapters.NewDatabase(cfg.Database)
internal.AssertNoError(err)
- database, err := adapters.NewSqlRepository(rawDb)
+ database, err := adapters.NewSqlRepository(rawDb, cfg)
internal.AssertNoError(err)
wireGuard, err := wireguard.NewControllerManager(cfg)
diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md
index ded802b..295e362 100644
--- a/docs/documentation/configuration/overview.md
+++ b/docs/documentation/configuration/overview.md
@@ -157,12 +157,14 @@ More advanced options are found in the subsequent `Advanced` section.
### `create_default_peer`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER`
-- **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for **all** server interfaces.
+- **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for all server interfaces where the "Create default peer" flag is set.
+- **Important:** This option is only effective for interfaces where the "Create default peer" flag is set (via the UI).
### `create_default_peer_on_creation`
- **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION`
-- **Description:** If an LDAP user is created (e.g., through LDAP sync) and has no peers, automatically create a new WireGuard peer for **all** server interfaces.
+- **Description:** If an LDAP user is created (e.g., through LDAP sync) and has no peers, automatically create a new WireGuard peer for all server interfaces where the "Create default peer" flag is set.
+- **Important:** This option requires [create_default_peer](#create_default_peer) to be enabled.
### `re_enable_peer_after_user_enable`
- **Default:** `true`
diff --git a/frontend/src/components/InterfaceEditModal.vue b/frontend/src/components/InterfaceEditModal.vue
index 2f94433..4b3dc4d 100644
--- a/frontend/src/components/InterfaceEditModal.vue
+++ b/frontend/src/components/InterfaceEditModal.vue
@@ -83,6 +83,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
formData.value.Identifier = interfaces.Prepared.Identifier
formData.value.DisplayName = interfaces.Prepared.DisplayName
formData.value.Mode = interfaces.Prepared.Mode
+ formData.value.CreateDefaultPeer = interfaces.Prepared.CreateDefaultPeer
formData.value.Backend = interfaces.Prepared.Backend
formData.value.PublicKey = interfaces.Prepared.PublicKey
@@ -122,6 +123,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
formData.value.Identifier = selectedInterface.value.Identifier
formData.value.DisplayName = selectedInterface.value.DisplayName
formData.value.Mode = selectedInterface.value.Mode
+ formData.value.CreateDefaultPeer = selectedInterface.value.CreateDefaultPeer
formData.value.Backend = selectedInterface.value.Backend
formData.value.PublicKey = selectedInterface.value.PublicKey
@@ -487,6 +489,10 @@ async function del() {
+
+
+
+
diff --git a/frontend/src/helpers/models.js b/frontend/src/helpers/models.js
index 5c0d1d7..d78f08c 100644
--- a/frontend/src/helpers/models.js
+++ b/frontend/src/helpers/models.js
@@ -4,6 +4,7 @@ export function freshInterface() {
Disabled: false,
DisplayName: "",
Identifier: "",
+ CreateDefaultPeer: false,
Mode: "server",
Backend: "local",
diff --git a/frontend/src/lang/translations/de.json b/frontend/src/lang/translations/de.json
index 07c9f8b..a51e534 100644
--- a/frontend/src/lang/translations/de.json
+++ b/frontend/src/lang/translations/de.json
@@ -469,6 +469,9 @@
"disabled": {
"label": "Schnittstelle deaktiviert"
},
+ "create-default-peer": {
+ "label": "Peer für neue Benutzer automatisch erstellen"
+ },
"save-config": {
"label": "wg-quick Konfiguration automatisch speichern"
},
diff --git a/frontend/src/lang/translations/en.json b/frontend/src/lang/translations/en.json
index 58f6de7..fe7bb27 100644
--- a/frontend/src/lang/translations/en.json
+++ b/frontend/src/lang/translations/en.json
@@ -469,6 +469,9 @@
"disabled": {
"label": "Interface Disabled"
},
+ "create-default-peer": {
+ "label": "Create default peer for new users"
+ },
"save-config": {
"label": "Automatically save wg-quick config"
},
diff --git a/internal/adapters/database.go b/internal/adapters/database.go
index 0d470e7..e689204 100644
--- a/internal/adapters/database.go
+++ b/internal/adapters/database.go
@@ -24,7 +24,7 @@ import (
)
// SchemaVersion describes the current database schema version. It must be incremented if a manual migration is needed.
-var SchemaVersion uint64 = 1
+var SchemaVersion uint64 = 2
// SysStat stores the current database schema version and the timestamp when it was applied.
type SysStat struct {
@@ -179,13 +179,15 @@ func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
// SqlRepo is a SQL database repository implementation.
// Currently, it supports MySQL, SQLite, Microsoft SQL and Postgresql database systems.
type SqlRepo struct {
- db *gorm.DB
+ db *gorm.DB
+ cfg *config.Config
}
// NewSqlRepository creates a new SqlRepo instance.
-func NewSqlRepository(db *gorm.DB) (*SqlRepo, error) {
+func NewSqlRepository(db *gorm.DB, cfg *config.Config) (*SqlRepo, error) {
repo := &SqlRepo{
- db: db,
+ db: db,
+ cfg: cfg,
}
if err := repo.preCheck(); err != nil {
@@ -232,7 +234,9 @@ func (r *SqlRepo) migrate() error {
slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{}))
existingSysStat := SysStat{}
- r.db.Where("schema_version = ?", SchemaVersion).First(&existingSysStat)
+ r.db.Order("schema_version desc").First(&existingSysStat) // get latest version
+
+ // Migration: 0 --> 1
if existingSysStat.SchemaVersion == 0 {
sysStat := SysStat{
MigratedAt: time.Now(),
@@ -244,6 +248,27 @@ func (r *SqlRepo) migrate() error {
slog.Debug("sys-stat entry written", "schema_version", SchemaVersion)
}
+ // Migration: 1 --> 2
+ if existingSysStat.SchemaVersion == 1 {
+ // Preserve existing behavior for installations that had default-peer-creation enabled.
+ if r.cfg.Core.CreateDefaultPeer {
+ err := r.db.Model(&domain.Interface{}).
+ Where("type = ?", domain.InterfaceTypeServer).
+ Update("create_default_peer", true).Error
+ if err != nil {
+ return fmt.Errorf("failed to migrate interface flags for schema version %d: %w", SchemaVersion, err)
+ }
+ slog.Debug("migrated interface create_default_peer flags", "schema_version", SchemaVersion)
+ }
+ sysStat := SysStat{
+ MigratedAt: time.Now(),
+ SchemaVersion: SchemaVersion,
+ }
+ if err := r.db.Create(&sysStat).Error; err != nil {
+ return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", SchemaVersion, err)
+ }
+ }
+
return nil
}
diff --git a/internal/app/api/v0/handlers/endpoint_config.go b/internal/app/api/v0/handlers/endpoint_config.go
index 1c5d744..db6cc73 100644
--- a/internal/app/api/v0/handlers/endpoint_config.go
+++ b/internal/app/api/v0/handlers/endpoint_config.go
@@ -145,6 +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,
})
}
}
diff --git a/internal/app/api/v0/model/models.go b/internal/app/api/v0/model/models.go
index 07e2eba..2fbe40a 100644
--- a/internal/app/api/v0/model/models.go
+++ b/internal/app/api/v0/model/models.go
@@ -14,6 +14,7 @@ type Settings struct {
MinPasswordLength int `json:"MinPasswordLength"`
AvailableBackends []SettingsBackendNames `json:"AvailableBackends"`
LoginFormVisible bool `json:"LoginFormVisible"`
+ CreateDefaultPeer bool `json:"CreateDefaultPeer"`
}
type SettingsBackendNames struct {
diff --git a/internal/app/api/v0/model/models_interface.go b/internal/app/api/v0/model/models_interface.go
index 1b22d02..87ef658 100644
--- a/internal/app/api/v0/model/models_interface.go
+++ b/internal/app/api/v0/model/models_interface.go
@@ -9,15 +9,16 @@ import (
)
type Interface struct {
- Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0
- DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface
- Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any'
- Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ...
- PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface
- PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
- Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
- DisabledReason string `json:"DisabledReason"` // the reason why the interface has been disabled
- SaveConfig bool `json:"SaveConfig"` // automatically persist config changes to the wgX.conf file
+ Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0
+ DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface
+ Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any'
+ Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ...
+ PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface
+ PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
+ Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
+ DisabledReason string `json:"DisabledReason"` // the reason why the interface has been disabled
+ SaveConfig bool `json:"SaveConfig"` // automatically persist config changes to the wgX.conf file
+ CreateDefaultPeer bool `json:"CreateDefaultPeer"` // if true, default peers will be created for this interface
ListenPort int `json:"ListenPort"` // the listening port, for example: 51820
Addresses []string `json:"Addresses"` // the interface ip addresses
@@ -65,6 +66,7 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason,
SaveConfig: src.SaveConfig,
+ CreateDefaultPeer: src.CreateDefaultPeer,
ListenPort: src.ListenPort,
Addresses: domain.CidrsToStringSlice(src.Addresses),
Dns: internal.SliceString(src.DnsStr),
@@ -151,6 +153,7 @@ func NewDomainInterface(src *Interface) *domain.Interface {
PreDown: src.PreDown,
PostDown: src.PostDown,
SaveConfig: src.SaveConfig,
+ CreateDefaultPeer: src.CreateDefaultPeer,
DisplayName: src.DisplayName,
Type: domain.InterfaceType(src.Mode),
Backend: domain.InterfaceBackend(src.Backend),
diff --git a/internal/app/wireguard/wireguard_interfaces.go b/internal/app/wireguard/wireguard_interfaces.go
index 6f1f32b..867fab4 100644
--- a/internal/app/wireguard/wireguard_interfaces.go
+++ b/internal/app/wireguard/wireguard_interfaces.go
@@ -374,6 +374,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,
DriverType: "",
Disabled: nil,
DisabledReason: "",
diff --git a/internal/app/wireguard/wireguard_peers.go b/internal/app/wireguard/wireguard_peers.go
index e42af28..46dfef3 100644
--- a/internal/app/wireguard/wireguard_peers.go
+++ b/internal/app/wireguard/wireguard_peers.go
@@ -35,6 +35,10 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti
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
})
diff --git a/internal/app/wireguard/wireguard_peers_test.go b/internal/app/wireguard/wireguard_peers_test.go
index 707d015..8a08122 100644
--- a/internal/app/wireguard/wireguard_peers_test.go
+++ b/internal/app/wireguard/wireguard_peers_test.go
@@ -78,7 +78,12 @@ func (f *mockDB) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceId
func (f *mockDB) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error) {
return nil, nil
}
-func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) { return nil, nil }
+func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
+ if f.iface != nil {
+ return []domain.Interface{*f.iface}, nil
+ }
+ return nil, nil
+}
func (f *mockDB) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error) {
return nil, nil
}
@@ -192,3 +197,58 @@ func TestCreatePeer_SetsIdentifier_FromPublicKey(t *testing.T) {
t.Fatalf("expected peer with identifier %q to be saved in DB", expectedId)
}
}
+
+func TestCreateDefaultPeer_RespectsInterfaceFlag(t *testing.T) {
+ // Arrange
+ cfg := &config.Config{}
+ cfg.Core.CreateDefaultPeer = true
+
+ bus := &mockBus{}
+ ctrlMgr := &ControllerManager{
+ controllers: map[domain.InterfaceBackend]backendInstance{
+ config.LocalBackendName: {Implementation: &mockController{}},
+ },
+ }
+
+ db := &mockDB{
+ iface: &domain.Interface{
+ Identifier: "wg0",
+ Type: domain.InterfaceTypeServer,
+ CreateDefaultPeer: false, // Flag is disabled!
+ },
+ }
+
+ m := Manager{
+ cfg: cfg,
+ bus: bus,
+ db: db,
+ wg: ctrlMgr,
+ }
+
+ userId := domain.UserIdentifier("user@example.com")
+ ctx := domain.SetUserInfo(context.Background(), &domain.ContextUserInfo{Id: userId, IsAdmin: true})
+
+ // Act
+ err := m.CreateDefaultPeer(ctx, userId)
+
+ // Assert
+ if err != nil {
+ t.Fatalf("CreateDefaultPeer returned error: %v", err)
+ }
+
+ if len(db.savedPeers) != 0 {
+ t.Fatalf("expected no peers to be created because interface flag is false, but got %d", len(db.savedPeers))
+ }
+
+ // Now enable the flag and try again
+ db.iface.CreateDefaultPeer = true
+ err = m.CreateDefaultPeer(ctx, userId)
+
+ if err != nil {
+ t.Fatalf("CreateDefaultPeer returned error after enabling flag: %v", err)
+ }
+
+ if len(db.savedPeers) != 1 {
+ t.Fatalf("expected 1 peer to be created because interface flag is true, but got %d", len(db.savedPeers))
+ }
+}
diff --git a/internal/domain/interface.go b/internal/domain/interface.go
index b71fe16..d8e4f0a 100644
--- a/internal/domain/interface.go
+++ b/internal/domain/interface.go
@@ -53,12 +53,13 @@ type Interface struct {
SaveConfig bool // automatically persist config changes to the wgX.conf file
// WG Portal specific
- DisplayName string // a nice display name/ description for the interface
- Type InterfaceType // the interface type, either InterfaceTypeServer or InterfaceTypeClient
- Backend InterfaceBackend // the backend that is used to manage the interface (wgctrl, mikrotik, ...)
- DriverType string // the interface driver type (linux, software, ...)
- Disabled *time.Time `gorm:"index"` // flag that specifies if the interface is enabled (up) or not (down)
- DisabledReason string // the reason why the interface has been disabled
+ DisplayName string // a nice display name/ description for the interface
+ Type InterfaceType // the interface type, either InterfaceTypeServer or InterfaceTypeClient
+ CreateDefaultPeer bool // if true, default peers will be created for this interface
+ Backend InterfaceBackend // the backend that is used to manage the interface (wgctrl, mikrotik, ...)
+ DriverType string // the interface driver type (linux, software, ...)
+ Disabled *time.Time `gorm:"index"` // flag that specifies if the interface is enabled (up) or not (down)
+ DisabledReason string // the reason why the interface has been disabled
// Default settings for the peer, used for new peers, those settings will be published to ConfigOption options of
// the peer config