Files
netbird/client/ui/profile.go
Riccardo Manfrin 2bcea9d582 [client] add MDM configuration profile support (Windows registry + macOS plist) (#6374)
* Initial scaffolding

* Applies MDM override

* Unit tests

* Helpers business logic

* Return error if trying to modify any config that is gated by MDM

* Add ManagedFields to returned config over GetConfig

* Adds initial 101 MDM policy business logic testing

* gRPC MDM changes

* MDM Name scoping for clarity

* Implements windows loading of MDM policy

* Adds missing WGPort config

* Cleanup setupKey to align to linear

* Align split tunnel code

* Adds some log

* Prefix every log with MDM

* Adds debug config cobra command

This can be useful for troubleshooting and checking config
now that its resolution is not trivial

defaults > config > env cars > CLI/UI > MDM

* Adds MDM 1m diff checker & reloader

* Adds also up/start after cancel

* Publishes event for UI to sync upon MDM changes

* Add events to resync UI to actual config

This also provide fixup for UI no aligning to changed config when coming from cli up with config flags.

* UI behavior conflicts relaxation

UI sends full config snapshot with all values. It doesn't
make sense to block it if the values are aligned with the
values constrained by the MDM policy. It's just simplier
to allow values that are compliant. (this goes for the CLI
as well at this point)

* Lock toggle Settngs

* Advanced Settings locking

* Fixup presharedkey

* Apply MDM locks

* Toggle gray in/out for Advanced Settings

* Adds support for disabling of Profiles and UpdateSettings feature flags

* Adds Gate Login as well when --disable-update-settings=true is given to service

This commit tries to settle things with an old PR-4237 which had relaxed
the case where the SetConfig returned an `Unavailable` code error.

Under this circumnstance the PR allowed the upFunc to just emit a warning and
progress further with the login gRPC. Since the login call is consuming
the --management-url coming from the `up` command, it might be possible
to abuse the "Unavailable" code to inject a management URL that is different
from the configured one even though the --disable-update-settings is set
to true (?)

* Evaluate disable-update-settings errors only when there's an actual override

* [UI] Fixup advanced Settings

* [UI] Fixup for preshared key

* [UI] Fixup for profile enable/disable toggle

We need to align the initial state to evaluate the delta in case.

The initial state has to be "true" since the profile starts visible.
Then we receive MDM and transition the cache bool value to the actual
MDM imposed state

* Enforces disable networks

* [UI] Aligns to "enable/disable once on change only"

* Fixup: MDM wins. always

* Removes --disable-advanced-settings

It was a typo in our meetings. the actual thing is --disable-update-settings

* [PROTO] Removes --disable-advanced-settings

* [UI] Removes --disable-advanced-settings

* Pins feat profile retrieval to notif event

* [UI] Fix for "hide" not working when propagating to parent with children

* Adds dep for reading plist files

* Introduces support for darwing plist loading

* Tests MDM config reload via ticker

* [PROVISIONING] ADMX/ADML/PS/bash scripts/templates

* CI fixes

- Add docstrings to `mdm_integration`
- refactor for cognitive complexity
- mod tidy

* Linting

* Add docstrings to `mdm_integration`

* nil,nil is no policy and no error. Allow it

* nil,nil is no policy and no error. Allow it

* exclude MDM profile adminstrated keys data from debug bundle

* Fixes Rosenpass left disable after MDM unlock

* Partial revert coderabbit added docstrings

* Renaming fix

* Avoid locking on clientRunning bool when the connection is aborted for whatever reason

We want to just signal this through the giveUpChan, we will manage the signal from
the waiter side and in case set it to false there. THis way we avoid locking,
which should allow the MDM down+wait_for_term_chan_signal_+up procedure

clientRunning is used to signal two different conditions here:

1. the initialization procedure is over (we have an engine)
2. the connection being up (or being attempted)

Probably these two functionalities should not alias, and the failure of the second condition
(because of any error) should just drive a reconnection (currently it's not happening,
and we silently go idle).
OR, mor probably, the two things are the SAME and there should not exist a case where
we did the "Up" initialization and connection attempt but we are not still attempting it.

* Moves test helper at te very bottom

* Addresses github comments

* No lock no copy

* Prevents engine not stopping within 10 secs from being paired by another instance

We instead juts SKIP updating the policy, so
1. the MDM ticker will kick in 1 minute time,
2. find the policy misaligned,
3. enter the onMDMPolicyChange,
4. find the s.clientRunning == true
   (because it is set to false only in server cleanupConnection,
   and not by s.actCancel())
5. call s.actCancel() again if not nil
6. immediately return from <-s.clientGiveUpChan
7. finally call s.restartEngineForMDMLocked()

* Since we ARE running there should be a config

If the config was cancelled midflight, connect will abort later on

* DisableAutoConnect should not stop a running connection.

DisableAutoConnect should just avoid the connection attempts *when the service starts*.
If we are started and we are up and running, DisableAutoConnect should not kick in.

Another PR will follow about this topic

* Removes unused vars

* Moves callback into Run method arg

* align comment to removal of DisableAutoConnect

DisableAutoConnect should just avoid the connection attempts *when the service starts*.
If we are started and we are up and running, DisableAutoConnect should not kick in

* Removes unused managed_fields data.

This was initially used to drive the UI but approach changed
to reload config/features upon notifications which makes this data redundant.

* Reorder stuff

* Unexport unrequired vars/functions

PoliciesEqual → policiesEqual
AllKeys → allKeys

* Adds list of MDM managed fields in the debug bundle
2026-06-12 12:28:49 +02:00

752 lines
18 KiB
Go

//go:build !(linux && 386)
package main
import (
"context"
"errors"
"fmt"
"os/user"
"slices"
"sort"
"sync"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"fyne.io/systray"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
)
// showProfilesUI creates and displays the Profiles window with a list of existing profiles,
// a button to add new profiles, allows removal, and lets the user switch the active profile.
func (s *serviceClient) showProfilesUI() {
profiles, err := s.getProfiles()
if err != nil {
log.Errorf("get profiles: %v", err)
return
}
var refresh func()
// List widget for profiles
list := widget.NewList(
func() int { return len(profiles) },
func() fyne.CanvasObject {
// Each item: Selected indicator, Name, spacer, Select, Logout & Remove buttons
return container.NewHBox(
widget.NewLabel(""), // indicator
widget.NewLabel(""), // profile name
layout.NewSpacer(),
widget.NewButton("Select", nil),
widget.NewButton("Deregister", nil),
widget.NewButton("Remove", nil),
)
},
func(i widget.ListItemID, item fyne.CanvasObject) {
// Populate each row
row := item.(*fyne.Container)
indicator := row.Objects[0].(*widget.Label)
nameLabel := row.Objects[1].(*widget.Label)
selectBtn := row.Objects[3].(*widget.Button)
logoutBtn := row.Objects[4].(*widget.Button)
removeBtn := row.Objects[5].(*widget.Button)
profile := profiles[i]
// Show a checkmark if selected
if profile.IsActive {
indicator.SetText("✓")
} else {
indicator.SetText("")
}
nameLabel.SetText(profile.Name)
// Configure Select/Active button
selectBtn.SetText(func() string {
if profile.IsActive {
return "Active"
}
return "Select"
}())
selectBtn.OnTapped = func() {
if profile.IsActive {
return // already active
}
// confirm switch
dialog.ShowConfirm(
"Switch Profile",
fmt.Sprintf("Are you sure you want to switch to '%s'?", profile.Name),
func(confirm bool) {
if !confirm {
return
}
// switch
err = s.switchProfile(profile.Name)
if err != nil {
log.Errorf("failed to switch profile: %v", err)
dialog.ShowError(errors.New("failed to select profile"), s.wProfiles)
return
}
dialog.ShowInformation(
"Profile Switched",
fmt.Sprintf("Profile '%s' switched successfully", profile.Name),
s.wProfiles,
)
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
log.Errorf("failed to get daemon client: %v", err)
return
}
status, err := conn.Status(s.ctx, &proto.StatusRequest{})
if err != nil {
log.Errorf("failed to get status after switching profile: %v", err)
return
}
if status.Status == string(internal.StatusConnected) {
if err := s.menuDownClick(); err != nil {
log.Errorf("failed to handle down click after switching profile: %v", err)
dialog.ShowError(fmt.Errorf("failed to handle down click"), s.wProfiles)
return
}
}
// update slice flags
refresh()
},
s.wProfiles,
)
}
logoutBtn.Show()
logoutBtn.SetText("Deregister")
logoutBtn.OnTapped = func() {
s.handleProfileLogout(profile.Name, refresh)
}
// Remove profile
removeBtn.SetText("Remove")
removeBtn.OnTapped = func() {
dialog.ShowConfirm(
"Delete Profile",
fmt.Sprintf("Are you sure you want to delete '%s'?", profile.Name),
func(confirm bool) {
if !confirm {
return
}
err = s.removeProfile(profile.Name)
if err != nil {
log.Errorf("failed to remove profile: %v", err)
dialog.ShowError(fmt.Errorf("failed to remove profile"), s.wProfiles)
return
}
dialog.ShowInformation(
"Profile Removed",
fmt.Sprintf("Profile '%s' removed successfully", profile.Name),
s.wProfiles,
)
// update slice
refresh()
},
s.wProfiles,
)
}
},
)
refresh = func() {
newProfiles, err := s.getProfiles()
if err != nil {
dialog.ShowError(err, s.wProfiles)
return
}
profiles = newProfiles // update the slice
list.Refresh() // tell Fyne to re-call length/update on every visible row
}
// Button to add a new profile
newBtn := widget.NewButton("New Profile", func() {
nameEntry := widget.NewEntry()
nameEntry.SetPlaceHolder("Enter Profile Name")
formItems := []*widget.FormItem{{Text: "Name:", Widget: nameEntry}}
dlg := dialog.NewForm(
"New Profile",
"Create",
"Cancel",
formItems,
func(confirm bool) {
if !confirm {
return
}
name := nameEntry.Text
if name == "" {
dialog.ShowError(errors.New("profile name cannot be empty"), s.wProfiles)
return
}
// add profile
err = s.addProfile(name)
if err != nil {
log.Errorf("failed to create profile: %v", err)
dialog.ShowError(fmt.Errorf("failed to create profile"), s.wProfiles)
return
}
dialog.ShowInformation(
"Profile Created",
fmt.Sprintf("Profile '%s' created successfully", name),
s.wProfiles,
)
// update slice
refresh()
},
s.wProfiles,
)
// make dialog wider
dlg.Resize(fyne.NewSize(350, 150))
dlg.Show()
})
// Assemble window content
content := container.NewBorder(nil, newBtn, nil, nil, list)
s.wProfiles = s.app.NewWindow("NetBird Profiles")
s.wProfiles.SetContent(content)
s.wProfiles.Resize(fyne.NewSize(400, 300))
s.wProfiles.SetOnClosed(s.cancel)
s.wProfiles.Show()
}
func (s *serviceClient) addProfile(profileName string) error {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
return fmt.Errorf(getClientFMT, err)
}
currUser, err := user.Current()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
_, err = conn.AddProfile(s.ctx, &proto.AddProfileRequest{
ProfileName: profileName,
Username: currUser.Username,
})
if err != nil {
return fmt.Errorf("add profile: %w", err)
}
return nil
}
func (s *serviceClient) switchProfile(profileName string) error {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
return fmt.Errorf(getClientFMT, err)
}
currUser, err := user.Current()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
if _, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{
ProfileName: &profileName,
Username: &currUser.Username,
}); err != nil {
return fmt.Errorf("switch profile failed: %w", err)
}
err = s.profileManager.SwitchProfile(profileName)
if err != nil {
return fmt.Errorf("switch profile: %w", err)
}
return nil
}
func (s *serviceClient) removeProfile(profileName string) error {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
return fmt.Errorf(getClientFMT, err)
}
currUser, err := user.Current()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
_, err = conn.RemoveProfile(s.ctx, &proto.RemoveProfileRequest{
ProfileName: profileName,
Username: currUser.Username,
})
if err != nil {
return fmt.Errorf("remove profile: %w", err)
}
return nil
}
type Profile struct {
Name string
IsActive bool
}
func (s *serviceClient) getProfiles() ([]Profile, error) {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
return nil, fmt.Errorf(getClientFMT, err)
}
currUser, err := user.Current()
if err != nil {
return nil, fmt.Errorf("get current user: %w", err)
}
profilesResp, err := conn.ListProfiles(s.ctx, &proto.ListProfilesRequest{
Username: currUser.Username,
})
if err != nil {
return nil, fmt.Errorf("list profiles: %w", err)
}
var profiles []Profile
for _, profile := range profilesResp.Profiles {
profiles = append(profiles, Profile{
Name: profile.Name,
IsActive: profile.IsActive,
})
}
return profiles, nil
}
func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback func()) {
dialog.ShowConfirm(
"Deregister",
fmt.Sprintf("Are you sure you want to deregister from '%s'?", profileName),
func(confirm bool) {
if !confirm {
return
}
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
log.Errorf("failed to get service client: %v", err)
dialog.ShowError(fmt.Errorf("failed to connect to service"), s.wProfiles)
return
}
currUser, err := user.Current()
if err != nil {
log.Errorf("failed to get current user: %v", err)
dialog.ShowError(fmt.Errorf("failed to get current user"), s.wProfiles)
return
}
username := currUser.Username
_, err = conn.Logout(s.ctx, &proto.LogoutRequest{
ProfileName: &profileName,
Username: &username,
})
if err != nil {
log.Errorf("logout failed: %v", err)
dialog.ShowError(fmt.Errorf("deregister failed"), s.wProfiles)
return
}
dialog.ShowInformation(
"Deregistered",
fmt.Sprintf("Successfully deregistered from '%s'", profileName),
s.wProfiles,
)
refreshCallback()
},
s.wProfiles,
)
}
type subItem struct {
*systray.MenuItem
ctx context.Context
cancel context.CancelFunc
}
type profileMenu struct {
mu sync.Mutex
ctx context.Context
serviceClient *serviceClient
profileManager *profilemanager.ProfileManager
eventHandler *eventHandler
profileMenuItem *systray.MenuItem
emailMenuItem *systray.MenuItem
profileSubItems []*subItem
manageProfilesSubItem *subItem
logoutSubItem *subItem
profilesState []Profile
downClickCallback func() error
upClickCallback func(context.Context) error
getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error)
loadSettingsCallback func()
app fyne.App
}
type newProfileMenuArgs struct {
ctx context.Context
serviceClient *serviceClient
profileManager *profilemanager.ProfileManager
eventHandler *eventHandler
profileMenuItem *systray.MenuItem
emailMenuItem *systray.MenuItem
downClickCallback func() error
upClickCallback func(context.Context) error
getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error)
loadSettingsCallback func()
app fyne.App
}
func newProfileMenu(args newProfileMenuArgs) *profileMenu {
p := profileMenu{
ctx: args.ctx,
serviceClient: args.serviceClient,
profileManager: args.profileManager,
eventHandler: args.eventHandler,
profileMenuItem: args.profileMenuItem,
emailMenuItem: args.emailMenuItem,
downClickCallback: args.downClickCallback,
upClickCallback: args.upClickCallback,
getSrvClientCallback: args.getSrvClientCallback,
loadSettingsCallback: args.loadSettingsCallback,
app: args.app,
}
p.emailMenuItem.Disable()
p.emailMenuItem.Hide()
p.refresh()
go p.updateMenu()
return &p
}
func (p *profileMenu) getProfiles() ([]Profile, error) {
conn, err := p.getSrvClientCallback(defaultFailTimeout)
if err != nil {
return nil, fmt.Errorf(getClientFMT, err)
}
currUser, err := user.Current()
if err != nil {
return nil, fmt.Errorf("get current user: %w", err)
}
profilesResp, err := conn.ListProfiles(p.ctx, &proto.ListProfilesRequest{
Username: currUser.Username,
})
if err != nil {
return nil, fmt.Errorf("list profiles: %w", err)
}
var profiles []Profile
for _, profile := range profilesResp.Profiles {
profiles = append(profiles, Profile{
Name: profile.Name,
IsActive: profile.IsActive,
})
}
return profiles, nil
}
func (p *profileMenu) refresh() {
p.mu.Lock()
defer p.mu.Unlock()
profiles, err := p.getProfiles()
if err != nil {
log.Errorf("failed to list profiles: %v", err)
return
}
// Clear existing profile items
p.clear(profiles)
currUser, err := user.Current()
if err != nil {
log.Errorf("failed to get current user: %v", err)
return
}
conn, err := p.getSrvClientCallback(defaultFailTimeout)
if err != nil {
log.Errorf("failed to get daemon client: %v", err)
return
}
activeProf, err := conn.GetActiveProfile(p.ctx, &proto.GetActiveProfileRequest{})
if err != nil {
log.Errorf("failed to get active profile: %v", err)
return
}
if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username {
activeProfState, err := p.profileManager.GetProfileState(activeProf.ProfileName)
if err != nil {
log.Warnf("failed to get active profile state: %v", err)
p.emailMenuItem.Hide()
} else if activeProfState.Email != "" {
p.emailMenuItem.SetTitle(fmt.Sprintf("(%s)", activeProfState.Email))
p.emailMenuItem.Show()
}
}
for _, profile := range profiles {
item := p.profileMenuItem.AddSubMenuItem(profile.Name, "")
if profile.IsActive {
item.Check()
}
ctx, cancel := context.WithCancel(context.Background())
p.profileSubItems = append(p.profileSubItems, &subItem{item, ctx, cancel})
go func() {
for {
select {
case <-ctx.Done():
return // context cancelled
case _, ok := <-item.ClickedCh:
if !ok {
return // channel closed
}
// Handle profile selection
if profile.IsActive {
log.Infof("Profile '%s' is already active", profile.Name)
return
}
conn, err := p.getSrvClientCallback(defaultFailTimeout)
if err != nil {
log.Errorf("failed to get daemon client: %v", err)
return
}
_, err = conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{
ProfileName: &profile.Name,
Username: &currUser.Username,
})
if err != nil {
log.Errorf("failed to switch profile: %v", err)
// show notification dialog
p.serviceClient.notifier.Send("Error", "Failed to switch profile")
return
}
err = p.profileManager.SwitchProfile(profile.Name)
if err != nil {
log.Errorf("failed to switch profile '%s': %v", profile.Name, err)
return
}
log.Infof("Switched to profile '%s'", profile.Name)
status, err := conn.Status(ctx, &proto.StatusRequest{})
if err != nil {
log.Errorf("failed to get status after switching profile: %v", err)
return
}
if status.Status == string(internal.StatusConnected) {
if err := p.downClickCallback(); err != nil {
log.Errorf("failed to handle down click after switching profile: %v", err)
}
}
if p.serviceClient.connectCancel != nil {
p.serviceClient.connectCancel()
}
connectCtx, connectCancel := context.WithCancel(p.ctx)
p.serviceClient.connectCancel = connectCancel
if err := p.upClickCallback(connectCtx); err != nil {
log.Errorf("failed to handle up click after switching profile: %v", err)
}
connectCancel()
p.refresh()
p.loadSettingsCallback()
}
}
}()
}
ctx, cancel := context.WithCancel(context.Background())
manageItem := p.profileMenuItem.AddSubMenuItem("Manage Profiles", "")
p.manageProfilesSubItem = &subItem{manageItem, ctx, cancel}
go func() {
for {
select {
case <-ctx.Done():
return
case _, ok := <-manageItem.ClickedCh:
if !ok {
return
}
p.eventHandler.runSelfCommand(p.ctx, "profiles", "true")
p.refresh()
p.loadSettingsCallback()
}
}
}()
// Add Logout menu item
ctx2, cancel2 := context.WithCancel(context.Background())
logoutItem := p.profileMenuItem.AddSubMenuItem("Deregister", "")
p.logoutSubItem = &subItem{logoutItem, ctx2, cancel2}
go func() {
for {
select {
case <-ctx2.Done():
return
case _, ok := <-logoutItem.ClickedCh:
if !ok {
return
}
if err := p.eventHandler.logout(p.ctx); err != nil {
log.Errorf("logout failed: %v", err)
p.serviceClient.notifier.Send("Error", "Failed to deregister")
} else {
p.serviceClient.notifier.Send("Success", "Deregistered successfully")
}
}
}
}()
if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username {
p.profileMenuItem.SetTitle(activeProf.ProfileName)
} else {
p.profileMenuItem.SetTitle(fmt.Sprintf("Profile: %s (User: %s)", activeProf.ProfileName, activeProf.Username))
p.emailMenuItem.Hide()
}
}
func (p *profileMenu) clear(profiles []Profile) {
for _, item := range p.profileSubItems {
item.Remove()
item.cancel()
}
p.profileSubItems = make([]*subItem, 0, len(profiles))
p.profilesState = profiles
if p.manageProfilesSubItem != nil {
p.manageProfilesSubItem.Remove()
p.manageProfilesSubItem.cancel()
p.manageProfilesSubItem = nil
}
if p.logoutSubItem != nil {
p.logoutSubItem.Remove()
p.logoutSubItem.cancel()
p.logoutSubItem = nil
}
}
// setEnabled greys out (Disable) the profile menu and every existing
// sub-item when the daemon reports the kill switch active, so the user
// sees the menu but cannot enter "Manage Profiles" or switch profile.
// Previously this used Hide() on the parent, but Fyne's systray on
// Windows does not propagate Hide() to a parent that already has
// children — the submenu kept popping up and accepting clicks. Disable
// is the reliable visual lock.
func (p *profileMenu) setEnabled(enabled bool) {
if p.profileMenuItem == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if enabled {
p.profileMenuItem.Enable()
p.profileMenuItem.SetTooltip("")
} else {
p.profileMenuItem.Disable()
p.profileMenuItem.SetTooltip("Profiles are disabled by daemon")
}
apply := func(item *systray.MenuItem) {
if item == nil {
return
}
if enabled {
item.Enable()
} else {
item.Disable()
}
}
for _, sub := range p.profileSubItems {
if sub != nil {
apply(sub.MenuItem)
}
}
if p.manageProfilesSubItem != nil {
apply(p.manageProfilesSubItem.MenuItem)
}
if p.logoutSubItem != nil {
apply(p.logoutSubItem.MenuItem)
}
}
func (p *profileMenu) updateMenu() {
// check every second
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// get profilesList
profiles, err := p.getProfiles()
if err != nil {
log.Errorf("failed to list profiles: %v", err)
continue
}
sort.Slice(profiles, func(i, j int) bool {
return profiles[i].Name < profiles[j].Name
})
p.mu.Lock()
state := p.profilesState
p.mu.Unlock()
sort.Slice(state, func(i, j int) bool {
return state[i].Name < state[j].Name
})
if slices.Equal(profiles, state) {
continue
}
p.refresh()
case <-p.ctx.Done():
return // context cancelled
}
}
}