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