[client] Feat: Support Multiple Profiles (#3980)

[client] Feat: Support Multiple Profiles (#3980)
This commit is contained in:
hakansa
2025-07-25 16:54:46 +03:00
committed by GitHub
parent e0d9306b05
commit cb8b6ca59b
53 changed files with 4651 additions and 768 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -8,8 +8,10 @@ import (
"errors"
"flag"
"fmt"
"net/url"
"os"
"os/exec"
"os/user"
"path"
"runtime"
"strconv"
@@ -34,11 +36,14 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/desktop"
"github.com/netbirdio/netbird/client/ui/event"
"github.com/netbirdio/netbird/client/ui/process"
"github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/version"
@@ -54,11 +59,11 @@ const (
)
func main() {
daemonAddr, showSettings, showNetworks, showLoginURL, showDebug, errorMsg, saveLogsInFile := parseFlags()
flags := parseFlags()
// Initialize file logging if needed.
var logFile string
if saveLogsInFile {
if flags.saveLogsInFile {
file, err := initLogFile()
if err != nil {
log.Errorf("error while initializing log: %v", err)
@@ -74,19 +79,28 @@ func main() {
a.SetIcon(fyne.NewStaticResource("netbird", iconDisconnected))
// Show error message window if needed.
if errorMsg != "" {
showErrorMessage(errorMsg)
if flags.errorMsg != "" {
showErrorMessage(flags.errorMsg)
return
}
// Create the service client (this also builds the settings or networks UI if requested).
client := newServiceClient(daemonAddr, logFile, a, showSettings, showNetworks, showLoginURL, showDebug)
client := newServiceClient(&newServiceClientArgs{
addr: flags.daemonAddr,
logFile: logFile,
app: a,
showSettings: flags.showSettings,
showNetworks: flags.showNetworks,
showLoginURL: flags.showLoginURL,
showDebug: flags.showDebug,
showProfiles: flags.showProfiles,
})
// Watch for theme/settings changes to update the icon.
go watchSettingsChanges(a, client)
// Run in window mode if any UI flag was set.
if showSettings || showNetworks || showDebug || showLoginURL {
if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles {
a.Run()
return
}
@@ -106,21 +120,35 @@ func main() {
systray.Run(client.onTrayReady, client.onTrayExit)
}
type cliFlags struct {
daemonAddr string
showSettings bool
showNetworks bool
showProfiles bool
showDebug bool
showLoginURL bool
errorMsg string
saveLogsInFile bool
}
// parseFlags reads and returns all needed command-line flags.
func parseFlags() (daemonAddr string, showSettings, showNetworks, showLoginURL, showDebug bool, errorMsg string, saveLogsInFile bool) {
func parseFlags() *cliFlags {
var flags cliFlags
defaultDaemonAddr := "unix:///var/run/netbird.sock"
if runtime.GOOS == "windows" {
defaultDaemonAddr = "tcp://127.0.0.1:41731"
}
flag.StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
flag.BoolVar(&showSettings, "settings", false, "run settings window")
flag.BoolVar(&showNetworks, "networks", false, "run networks window")
flag.BoolVar(&showLoginURL, "login-url", false, "show login URL in a popup window")
flag.BoolVar(&showDebug, "debug", false, "run debug window")
flag.StringVar(&errorMsg, "error-msg", "", "displays an error message window")
flag.BoolVar(&saveLogsInFile, "use-log-file", false, fmt.Sprintf("save logs in a file: %s/netbird-ui-PID.log", os.TempDir()))
flag.StringVar(&flags.daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
flag.BoolVar(&flags.showSettings, "settings", false, "run settings window")
flag.BoolVar(&flags.showNetworks, "networks", false, "run networks window")
flag.BoolVar(&flags.showProfiles, "profiles", false, "run profiles window")
flag.BoolVar(&flags.showDebug, "debug", false, "run debug window")
flag.StringVar(&flags.errorMsg, "error-msg", "", "displays an error message window")
flag.BoolVar(&flags.saveLogsInFile, "use-log-file", false, fmt.Sprintf("save logs in a file: %s/netbird-ui-PID.log", os.TempDir()))
flag.BoolVar(&flags.showLoginURL, "login-url", false, "show login URL in a popup window")
flag.Parse()
return
return &flags
}
// initLogFile initializes logging into a file.
@@ -168,6 +196,12 @@ var iconConnectingMacOS []byte
//go:embed assets/netbird-systemtray-error-macos.png
var iconErrorMacOS []byte
//go:embed assets/connected.png
var iconConnectedDot []byte
//go:embed assets/disconnected.png
var iconDisconnectedDot []byte
type serviceClient struct {
ctx context.Context
cancel context.CancelFunc
@@ -176,9 +210,13 @@ type serviceClient struct {
eventHandler *eventHandler
profileManager *profilemanager.ProfileManager
icAbout []byte
icConnected []byte
icConnectedDot []byte
icDisconnected []byte
icDisconnectedDot []byte
icUpdateConnected []byte
icUpdateDisconnected []byte
icConnecting []byte
@@ -189,6 +227,7 @@ type serviceClient struct {
mUp *systray.MenuItem
mDown *systray.MenuItem
mSettings *systray.MenuItem
mProfile *profileMenu
mAbout *systray.MenuItem
mGitHub *systray.MenuItem
mVersionUI *systray.MenuItem
@@ -214,7 +253,6 @@ type serviceClient struct {
// input elements for settings form
iMngURL *widget.Entry
iConfigFile *widget.Entry
iLogFile *widget.Entry
iPreSharedKey *widget.Entry
iInterfaceName *widget.Entry
@@ -247,6 +285,7 @@ type serviceClient struct {
isUpdateIconActive bool
showNetworks bool
wNetworks fyne.Window
wProfiles fyne.Window
eventManager *event.Manager
@@ -263,36 +302,50 @@ type menuHandler struct {
cancel context.CancelFunc
}
type newServiceClientArgs struct {
addr string
logFile string
app fyne.App
showSettings bool
showNetworks bool
showDebug bool
showLoginURL bool
showProfiles bool
}
// newServiceClient instance constructor
//
// This constructor also builds the UI elements for the settings window.
func newServiceClient(addr string, logFile string, a fyne.App, showSettings bool, showNetworks bool, showLoginURL bool, showDebug bool) *serviceClient {
func newServiceClient(args *newServiceClientArgs) *serviceClient {
ctx, cancel := context.WithCancel(context.Background())
s := &serviceClient{
ctx: ctx,
cancel: cancel,
addr: addr,
app: a,
logFile: logFile,
addr: args.addr,
app: args.app,
logFile: args.logFile,
sendNotification: false,
showAdvancedSettings: showSettings,
showNetworks: showNetworks,
showAdvancedSettings: args.showSettings,
showNetworks: args.showNetworks,
update: version.NewUpdate("nb/client-ui"),
}
s.eventHandler = newEventHandler(s)
s.profileManager = profilemanager.NewProfileManager()
s.setNewIcons()
switch {
case showSettings:
case args.showSettings:
s.showSettingsUI()
case showNetworks:
case args.showNetworks:
s.showNetworksUI()
case showLoginURL:
case args.showLoginURL:
s.showLoginURL()
case showDebug:
case args.showDebug:
s.showDebugUI()
case args.showProfiles:
s.showProfilesUI()
}
return s
@@ -300,6 +353,8 @@ func newServiceClient(addr string, logFile string, a fyne.App, showSettings bool
func (s *serviceClient) setNewIcons() {
s.icAbout = iconAbout
s.icConnectedDot = iconConnectedDot
s.icDisconnectedDot = iconDisconnectedDot
if s.app.Settings().ThemeVariant() == theme.VariantDark {
s.icConnected = iconConnectedDark
s.icDisconnected = iconDisconnected
@@ -342,8 +397,7 @@ func (s *serviceClient) showSettingsUI() {
s.wSettings.SetOnClosed(s.cancel)
s.iMngURL = widget.NewEntry()
s.iConfigFile = widget.NewEntry()
s.iConfigFile.Disable()
s.iLogFile = widget.NewEntry()
s.iLogFile.Disable()
s.iPreSharedKey = widget.NewPasswordEntry()
@@ -368,14 +422,22 @@ func (s *serviceClient) showSettingsUI() {
// getSettingsForm to embed it into settings window.
func (s *serviceClient) getSettingsForm() *widget.Form {
var activeProfName string
activeProf, err := s.profileManager.GetActiveProfile()
if err != nil {
log.Errorf("get active profile: %v", err)
} else {
activeProfName = activeProf.Name
}
return &widget.Form{
Items: []*widget.FormItem{
{Text: "Profile", Widget: widget.NewLabel(activeProfName)},
{Text: "Quantum-Resistance", Widget: s.sRosenpassPermissive},
{Text: "Interface Name", Widget: s.iInterfaceName},
{Text: "Interface Port", Widget: s.iInterfacePort},
{Text: "Management URL", Widget: s.iMngURL},
{Text: "Pre-shared Key", Widget: s.iPreSharedKey},
{Text: "Config File", Widget: s.iConfigFile},
{Text: "Log File", Widget: s.iLogFile},
{Text: "Network Monitor", Widget: s.sNetworkMonitor},
{Text: "Disable DNS", Widget: s.sDisableDNS},
@@ -416,27 +478,67 @@ func (s *serviceClient) getSettingsForm() *widget.Form {
s.managementURL = iMngURL
s.preSharedKey = s.iPreSharedKey.Text
loginRequest := proto.LoginRequest{
ManagementUrl: iMngURL,
IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd",
RosenpassPermissive: &s.sRosenpassPermissive.Checked,
InterfaceName: &s.iInterfaceName.Text,
WireguardPort: &port,
NetworkMonitor: &s.sNetworkMonitor.Checked,
DisableDns: &s.sDisableDNS.Checked,
DisableClientRoutes: &s.sDisableClientRoutes.Checked,
DisableServerRoutes: &s.sDisableServerRoutes.Checked,
BlockLanAccess: &s.sBlockLANAccess.Checked,
}
if s.iPreSharedKey.Text != censoredPreSharedKey {
loginRequest.OptionalPreSharedKey = &s.iPreSharedKey.Text
}
if err := s.restartClient(&loginRequest); err != nil {
log.Errorf("restarting client connection: %v", err)
currUser, err := user.Current()
if err != nil {
log.Errorf("get current user: %v", err)
return
}
var req proto.SetConfigRequest
req.ProfileName = activeProf.Name
req.Username = currUser.Username
if iMngURL != "" {
req.ManagementUrl = iMngURL
}
req.RosenpassPermissive = &s.sRosenpassPermissive.Checked
req.InterfaceName = &s.iInterfaceName.Text
req.WireguardPort = &port
req.NetworkMonitor = &s.sNetworkMonitor.Checked
req.DisableDns = &s.sDisableDNS.Checked
req.DisableClientRoutes = &s.sDisableClientRoutes.Checked
req.DisableServerRoutes = &s.sDisableServerRoutes.Checked
req.BlockLanAccess = &s.sBlockLANAccess.Checked
if s.iPreSharedKey.Text != censoredPreSharedKey {
req.OptionalPreSharedKey = &s.iPreSharedKey.Text
}
conn, err := s.getSrvClient(failFastTimeout)
if err != nil {
log.Errorf("get client: %v", err)
dialog.ShowError(fmt.Errorf("Failed to connect to the service: %v", err), s.wSettings)
return
}
_, err = conn.SetConfig(s.ctx, &req)
if err != nil {
log.Errorf("set config: %v", err)
dialog.ShowError(fmt.Errorf("Failed to set configuration: %v", err), s.wSettings)
return
}
status, err := conn.Status(s.ctx, &proto.StatusRequest{})
if err != nil {
log.Errorf("get service status: %v", err)
dialog.ShowError(fmt.Errorf("Failed to get service status: %v", err), s.wSettings)
return
}
if status.Status == string(internal.StatusConnected) {
// run down & up
_, err = conn.Down(s.ctx, &proto.DownRequest{})
if err != nil {
log.Errorf("down service: %v", err)
}
_, err = conn.Up(s.ctx, &proto.UpRequest{})
if err != nil {
log.Errorf("up service: %v", err)
dialog.ShowError(fmt.Errorf("Failed to reconnect: %v", err), s.wSettings)
return
}
}
}
},
OnCancel: func() {
@@ -452,8 +554,21 @@ func (s *serviceClient) login(openURL bool) (*proto.LoginResponse, error) {
return nil, err
}
activeProf, err := s.profileManager.GetActiveProfile()
if err != nil {
log.Errorf("get active profile: %v", err)
return nil, err
}
currUser, err := user.Current()
if err != nil {
return nil, fmt.Errorf("get current user: %w", err)
}
loginResp, err := conn.Login(s.ctx, &proto.LoginRequest{
IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd",
ProfileName: &activeProf.Name,
Username: &currUser.Username,
})
if err != nil {
log.Errorf("login to management URL with: %v", err)
@@ -461,15 +576,9 @@ func (s *serviceClient) login(openURL bool) (*proto.LoginResponse, error) {
}
if loginResp.NeedsSSOLogin && openURL {
err = open.Run(loginResp.VerificationURIComplete)
err = s.handleSSOLogin(loginResp, conn)
if err != nil {
log.Errorf("opening the verification uri in the browser failed: %v", err)
return nil, err
}
_, err = conn.WaitSSOLogin(s.ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode})
if err != nil {
log.Errorf("waiting sso login failed with: %v", err)
log.Errorf("handle SSO login failed: %v", err)
return nil, err
}
}
@@ -477,6 +586,34 @@ func (s *serviceClient) login(openURL bool) (*proto.LoginResponse, error) {
return loginResp, nil
}
func (s *serviceClient) handleSSOLogin(loginResp *proto.LoginResponse, conn proto.DaemonServiceClient) error {
err := open.Run(loginResp.VerificationURIComplete)
if err != nil {
log.Errorf("opening the verification uri in the browser failed: %v", err)
return err
}
resp, err := conn.WaitSSOLogin(s.ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode})
if err != nil {
log.Errorf("waiting sso login failed with: %v", err)
return err
}
if resp.Email != "" {
err := s.profileManager.SetActiveProfileState(&profilemanager.ProfileState{
Email: resp.Email,
})
if err != nil {
log.Warnf("failed to set profile state: %v", err)
} else {
s.mProfile.refresh()
}
}
return nil
}
func (s *serviceClient) menuUpClick() error {
systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting)
conn, err := s.getSrvClient(defaultFailTimeout)
@@ -575,6 +712,7 @@ func (s *serviceClient) updateStatus() error {
}
systray.SetTooltip("NetBird (Connected)")
s.mStatus.SetTitle("Connected")
s.mStatus.SetIcon(s.icConnectedDot)
s.mUp.Disable()
s.mDown.Enable()
s.mNetworks.Enable()
@@ -634,6 +772,7 @@ func (s *serviceClient) setDisconnectedStatus() {
}
systray.SetTooltip("NetBird (Disconnected)")
s.mStatus.SetTitle("Disconnected")
s.mStatus.SetIcon(s.icDisconnectedDot)
s.mDown.Disable()
s.mUp.Enable()
s.mNetworks.Disable()
@@ -658,7 +797,13 @@ func (s *serviceClient) onTrayReady() {
// setup systray menu items
s.mStatus = systray.AddMenuItem("Disconnected", "Disconnected")
s.mStatus.SetIcon(s.icDisconnectedDot)
s.mStatus.Disable()
profileMenuItem := systray.AddMenuItem("", "")
emailMenuItem := systray.AddMenuItem("", "")
s.mProfile = newProfileMenu(s.ctx, s.profileManager, *s.eventHandler, profileMenuItem, emailMenuItem, s.menuDownClick, s.menuUpClick, s.getSrvClient, s.loadSettings)
systray.AddSeparator()
s.mUp = systray.AddMenuItem("Connect", "Connect")
s.mDown = systray.AddMenuItem("Disconnect", "Disconnect")
@@ -790,7 +935,15 @@ func (s *serviceClient) getSrvClient(timeout time.Duration) (proto.DaemonService
// getSrvConfig from the service to show it in the settings window.
func (s *serviceClient) getSrvConfig() {
s.managementURL = internal.DefaultManagementURL
s.managementURL = profilemanager.DefaultManagementURL
_, err := s.profileManager.GetActiveProfile()
if err != nil {
log.Errorf("get active profile: %v", err)
return
}
var cfg *profilemanager.Config
conn, err := s.getSrvClient(failFastTimeout)
if err != nil {
@@ -798,48 +951,63 @@ func (s *serviceClient) getSrvConfig() {
return
}
cfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{})
currUser, err := user.Current()
if err != nil {
log.Errorf("get current user: %v", err)
return
}
activeProf, err := s.profileManager.GetActiveProfile()
if err != nil {
log.Errorf("get active profile: %v", err)
return
}
srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{
ProfileName: activeProf.Name,
Username: currUser.Username,
})
if err != nil {
log.Errorf("get config settings from server: %v", err)
return
}
if cfg.ManagementUrl != "" {
s.managementURL = cfg.ManagementUrl
cfg = protoConfigToConfig(srvCfg)
if cfg.ManagementURL.String() != "" {
s.managementURL = cfg.ManagementURL.String()
}
s.preSharedKey = cfg.PreSharedKey
s.RosenpassPermissive = cfg.RosenpassPermissive
s.interfaceName = cfg.InterfaceName
s.interfacePort = int(cfg.WireguardPort)
s.interfaceName = cfg.WgIface
s.interfacePort = cfg.WgPort
s.networkMonitor = cfg.NetworkMonitor
s.disableDNS = cfg.DisableDns
s.networkMonitor = *cfg.NetworkMonitor
s.disableDNS = cfg.DisableDNS
s.disableClientRoutes = cfg.DisableClientRoutes
s.disableServerRoutes = cfg.DisableServerRoutes
s.blockLANAccess = cfg.BlockLanAccess
s.blockLANAccess = cfg.BlockLANAccess
if s.showAdvancedSettings {
s.iMngURL.SetText(s.managementURL)
s.iConfigFile.SetText(cfg.ConfigFile)
s.iLogFile.SetText(cfg.LogFile)
s.iPreSharedKey.SetText(cfg.PreSharedKey)
s.iInterfaceName.SetText(cfg.InterfaceName)
s.iInterfacePort.SetText(strconv.Itoa(int(cfg.WireguardPort)))
s.iInterfaceName.SetText(cfg.WgIface)
s.iInterfacePort.SetText(strconv.Itoa(cfg.WgPort))
s.sRosenpassPermissive.SetChecked(cfg.RosenpassPermissive)
if !cfg.RosenpassEnabled {
s.sRosenpassPermissive.Disable()
}
s.sNetworkMonitor.SetChecked(cfg.NetworkMonitor)
s.sDisableDNS.SetChecked(cfg.DisableDns)
s.sNetworkMonitor.SetChecked(*cfg.NetworkMonitor)
s.sDisableDNS.SetChecked(cfg.DisableDNS)
s.sDisableClientRoutes.SetChecked(cfg.DisableClientRoutes)
s.sDisableServerRoutes.SetChecked(cfg.DisableServerRoutes)
s.sBlockLANAccess.SetChecked(cfg.BlockLanAccess)
s.sBlockLANAccess.SetChecked(cfg.BlockLANAccess)
}
if s.mNotifications == nil {
return
}
if cfg.DisableNotifications {
if cfg.DisableNotifications != nil && *cfg.DisableNotifications {
s.mNotifications.Uncheck()
} else {
s.mNotifications.Check()
@@ -849,6 +1017,58 @@ func (s *serviceClient) getSrvConfig() {
}
}
func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config {
var config profilemanager.Config
if cfg.ManagementUrl != "" {
parsed, err := url.Parse(cfg.ManagementUrl)
if err != nil {
log.Errorf("parse management URL: %v", err)
} else {
config.ManagementURL = parsed
}
}
if cfg.PreSharedKey != "" {
if cfg.PreSharedKey != censoredPreSharedKey {
config.PreSharedKey = cfg.PreSharedKey
} else {
config.PreSharedKey = ""
}
}
if cfg.AdminURL != "" {
parsed, err := url.Parse(cfg.AdminURL)
if err != nil {
log.Errorf("parse admin URL: %v", err)
} else {
config.AdminURL = parsed
}
}
config.WgIface = cfg.InterfaceName
if cfg.WireguardPort != 0 {
config.WgPort = int(cfg.WireguardPort)
} else {
config.WgPort = iface.DefaultWgPort
}
config.DisableAutoConnect = cfg.DisableAutoConnect
config.ServerSSHAllowed = &cfg.ServerSSHAllowed
config.RosenpassEnabled = cfg.RosenpassEnabled
config.RosenpassPermissive = cfg.RosenpassPermissive
config.DisableNotifications = &cfg.DisableNotifications
config.LazyConnectionEnabled = cfg.LazyConnectionEnabled
config.BlockInbound = cfg.BlockInbound
config.NetworkMonitor = &cfg.NetworkMonitor
config.DisableDNS = cfg.DisableDns
config.DisableClientRoutes = cfg.DisableClientRoutes
config.DisableServerRoutes = cfg.DisableServerRoutes
config.BlockLANAccess = cfg.BlockLanAccess
return &config
}
func (s *serviceClient) onUpdateAvailable() {
s.updateIndicationLock.Lock()
defer s.updateIndicationLock.Unlock()
@@ -880,7 +1100,22 @@ func (s *serviceClient) loadSettings() {
return
}
cfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{})
currUser, err := user.Current()
if err != nil {
log.Errorf("get current user: %v", err)
return
}
activeProf, err := s.profileManager.GetActiveProfile()
if err != nil {
log.Errorf("get active profile: %v", err)
return
}
cfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{
ProfileName: activeProf.Name,
Username: currUser.Username,
})
if err != nil {
log.Errorf("get config settings from server: %v", err)
return
@@ -936,41 +1171,37 @@ func (s *serviceClient) updateConfig() error {
blockInbound := s.mBlockInbound.Checked()
notificationsDisabled := !s.mNotifications.Checked()
loginRequest := proto.LoginRequest{
IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd",
activeProf, err := s.profileManager.GetActiveProfile()
if err != nil {
log.Errorf("get active profile: %v", err)
return err
}
currUser, err := user.Current()
if err != nil {
log.Errorf("get current user: %v", err)
return err
}
conn, err := s.getSrvClient(failFastTimeout)
if err != nil {
log.Errorf("get client: %v", err)
return err
}
req := proto.SetConfigRequest{
ProfileName: activeProf.Name,
Username: currUser.Username,
DisableAutoConnect: &disableAutoStart,
ServerSSHAllowed: &sshAllowed,
RosenpassEnabled: &rosenpassEnabled,
DisableAutoConnect: &disableAutoStart,
DisableNotifications: &notificationsDisabled,
LazyConnectionEnabled: &lazyConnectionEnabled,
BlockInbound: &blockInbound,
DisableNotifications: &notificationsDisabled,
}
if err := s.restartClient(&loginRequest); err != nil {
log.Errorf("restarting client connection: %v", err)
return err
}
return nil
}
// restartClient restarts the client connection.
func (s *serviceClient) restartClient(loginRequest *proto.LoginRequest) error {
ctx, cancel := context.WithTimeout(s.ctx, defaultFailTimeout)
defer cancel()
client, err := s.getSrvClient(failFastTimeout)
if err != nil {
return err
}
_, err = client.Login(ctx, loginRequest)
if err != nil {
return err
}
_, err = client.Up(ctx, &proto.UpRequest{})
if err != nil {
if _, err := conn.SetConfig(s.ctx, &req); err != nil {
log.Errorf("set config settings on server: %v", err)
return err
}

View File

@@ -2,6 +2,7 @@ package main
const (
settingsMenuDescr = "Settings of the application"
profilesMenuDescr = "Manage your profiles"
allowSSHMenuDescr = "Allow SSH connections"
autoConnectMenuDescr = "Connect automatically when the service starts"
quantumResistanceMenuDescr = "Enable post-quantum security via Rosenpass"

View File

@@ -433,7 +433,7 @@ func (s *serviceClient) collectDebugData(
var postUpStatusOutput string
if postUpStatus != nil {
overview := nbstatus.ConvertToStatusOutputOverview(postUpStatus, params.anonymize, "", nil, nil, nil, "")
overview := nbstatus.ConvertToStatusOutputOverview(postUpStatus, params.anonymize, "", nil, nil, nil, "", "")
postUpStatusOutput = nbstatus.ParseToFullDetailSummary(overview)
}
headerPostUp := fmt.Sprintf("----- NetBird post-up - Timestamp: %s", time.Now().Format(time.RFC3339))
@@ -450,7 +450,7 @@ func (s *serviceClient) collectDebugData(
var preDownStatusOutput string
if preDownStatus != nil {
overview := nbstatus.ConvertToStatusOutputOverview(preDownStatus, params.anonymize, "", nil, nil, nil, "")
overview := nbstatus.ConvertToStatusOutputOverview(preDownStatus, params.anonymize, "", nil, nil, nil, "", "")
preDownStatusOutput = nbstatus.ParseToFullDetailSummary(overview)
}
headerPreDown := fmt.Sprintf("----- NetBird pre-down - Timestamp: %s - Duration: %s",
@@ -581,7 +581,7 @@ func (s *serviceClient) createDebugBundle(anonymize bool, systemInfo bool, uploa
var statusOutput string
if statusResp != nil {
overview := nbstatus.ConvertToStatusOutputOverview(statusResp, anonymize, "", nil, nil, nil, "")
overview := nbstatus.ConvertToStatusOutputOverview(statusResp, anonymize, "", nil, nil, nil, "", "")
statusOutput = nbstatus.ParseToFullDetailSummary(overview)
}

601
client/ui/profile.go Normal file
View File

@@ -0,0 +1,601 @@
//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 & Remove buttons
return container.NewHBox(
widget.NewLabel(""), // indicator
widget.NewLabel(""), // profile name
layout.NewSpacer(),
widget.NewButton("Select", 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)
removeBtn := row.Objects[4].(*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(context.Background(), &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,
)
}
// 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
}
// remove
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(context.Background(), &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(context.Background(), &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(context.Background(), &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(context.Background(), &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
}
type subItem struct {
*systray.MenuItem
ctx context.Context
cancel context.CancelFunc
}
type profileMenu struct {
mu sync.Mutex
ctx context.Context
profileManager *profilemanager.ProfileManager
eventHandler eventHandler
profileMenuItem *systray.MenuItem
emailMenuItem *systray.MenuItem
profileSubItems []*subItem
manageProfilesSubItem *subItem
profilesState []Profile
downClickCallback func() error
upClickCallback func() error
getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error)
loadSettingsCallback func()
}
func newProfileMenu(ctx context.Context, profileManager *profilemanager.ProfileManager,
eventHandler eventHandler, profileMenuItem, emailMenuItem *systray.MenuItem,
downClickCallback, upClickCallback func() error,
getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error),
loadSettingsCallback func()) *profileMenu {
p := profileMenu{
ctx: ctx,
profileManager: profileManager,
eventHandler: eventHandler,
profileMenuItem: profileMenuItem,
emailMenuItem: emailMenuItem,
downClickCallback: downClickCallback,
upClickCallback: upClickCallback,
getSrvClientCallback: getSrvClientCallback,
loadSettingsCallback: loadSettingsCallback,
}
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)
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 err := p.upClickCallback(); err != nil {
log.Errorf("failed to handle up click after switching profile: %v", err)
}
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 // context cancelled
case _, ok := <-manageItem.ClickedCh:
if !ok {
return // channel closed
}
// Handle manage profiles click
p.eventHandler.runSelfCommand(p.ctx, "profiles", "true")
p.refresh()
p.loadSettingsCallback()
}
}
}()
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) {
// Clear existing profile items
for _, item := range p.profileSubItems {
item.Remove()
item.cancel()
}
p.profileSubItems = make([]*subItem, 0, len(profiles))
p.profilesState = profiles
if p.manageProfilesSubItem != nil {
// Remove the manage profiles item if it exists
p.manageProfilesSubItem.Remove()
p.manageProfilesSubItem.cancel()
p.manageProfilesSubItem = nil
}
}
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
}
}
}