Merge branch 'main' into feature/remote-debug-clean

# Conflicts:
#	client/cmd/testutil_test.go
#	client/internal/engine_test.go
#	client/server/server.go
#	client/server/server_test.go
#	client/status/status.go
#	go.mod
#	go.sum
#	management/internals/server/boot.go
#	management/internals/server/modules.go
#	management/internals/shared/grpc/server.go
#	management/server/account.go
#	management/server/account/manager.go
#	management/server/account_test.go
#	management/server/dns_test.go
#	management/server/http/testing/testing_tools/channel/channel.go
#	management/server/management_proto_test.go
#	management/server/management_test.go
#	management/server/nameserver_test.go
#	management/server/peer_test.go
#	management/server/route_test.go
#	shared/management/client/client_test.go
#	shared/management/proto/management.pb.go
This commit is contained in:
Zoltán Papp
2025-11-18 18:30:48 +01:00
240 changed files with 22880 additions and 5597 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+380 -182
View File
@@ -55,6 +55,7 @@ const (
const (
censoredPreSharedKey = "**********"
maxSSHJWTCacheTTL = 86_400 // 24 hours in seconds
)
func main() {
@@ -85,21 +86,22 @@ func main() {
// Create the service client (this also builds the settings or networks UI if requested).
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,
addr: flags.daemonAddr,
logFile: logFile,
app: a,
showSettings: flags.showSettings,
showNetworks: flags.showNetworks,
showLoginURL: flags.showLoginURL,
showDebug: flags.showDebug,
showProfiles: flags.showProfiles,
showQuickActions: flags.showQuickActions,
})
// Watch for theme/settings changes to update the icon.
go watchSettingsChanges(a, client)
// Run in window mode if any UI flag was set.
if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles {
if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles || flags.showQuickActions {
a.Run()
return
}
@@ -111,23 +113,29 @@ func main() {
return
}
if running {
log.Warnf("another process is running with pid %d, exiting", pid)
log.Infof("another process is running with pid %d, sending signal to show window", pid)
if err := sendShowWindowSignal(pid); err != nil {
log.Errorf("send signal to running instance: %v", err)
}
return
}
client.setupSignalHandler(client.ctx)
client.setDefaultFonts()
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
daemonAddr string
showSettings bool
showNetworks bool
showProfiles bool
showDebug bool
showLoginURL bool
showQuickActions bool
errorMsg string
saveLogsInFile bool
}
// parseFlags reads and returns all needed command-line flags.
@@ -143,6 +151,7 @@ func parseFlags() *cliFlags {
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.BoolVar(&flags.showQuickActions, "quick-actions", false, "run quick actions 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")
@@ -158,11 +167,9 @@ func initLogFile() (string, error) {
// watchSettingsChanges listens for Fyne theme/settings changes and updates the client icon.
func watchSettingsChanges(a fyne.App, client *serviceClient) {
settingsChangeChan := make(chan fyne.Settings)
a.Settings().AddChangeListener(settingsChangeChan)
for range settingsChangeChan {
a.Settings().AddListener(func(settings fyne.Settings) {
client.updateIcon()
}
})
}
// showErrorMessage displays an error message in a simple window.
@@ -259,25 +266,38 @@ type serviceClient struct {
iMTU *widget.Entry
// switch elements for settings form
sRosenpassPermissive *widget.Check
sNetworkMonitor *widget.Check
sDisableDNS *widget.Check
sDisableClientRoutes *widget.Check
sDisableServerRoutes *widget.Check
sBlockLANAccess *widget.Check
sRosenpassPermissive *widget.Check
sNetworkMonitor *widget.Check
sDisableDNS *widget.Check
sDisableClientRoutes *widget.Check
sDisableServerRoutes *widget.Check
sBlockLANAccess *widget.Check
sEnableSSHRoot *widget.Check
sEnableSSHSFTP *widget.Check
sEnableSSHLocalPortForward *widget.Check
sEnableSSHRemotePortForward *widget.Check
sDisableSSHAuth *widget.Check
iSSHJWTCacheTTL *widget.Entry
// observable settings over corresponding iMngURL and iPreSharedKey values.
managementURL string
preSharedKey string
RosenpassPermissive bool
interfaceName string
interfacePort int
mtu uint16
networkMonitor bool
disableDNS bool
disableClientRoutes bool
disableServerRoutes bool
blockLANAccess bool
managementURL string
preSharedKey string
RosenpassPermissive bool
interfaceName string
interfacePort int
mtu uint16
networkMonitor bool
disableDNS bool
disableClientRoutes bool
disableServerRoutes bool
blockLANAccess bool
enableSSHRoot bool
enableSSHSFTP bool
enableSSHLocalPortForward bool
enableSSHRemotePortForward bool
disableSSHAuth bool
sshJWTCacheTTL int
connected bool
update *version.Update
@@ -287,6 +307,7 @@ type serviceClient struct {
showNetworks bool
wNetworks fyne.Window
wProfiles fyne.Window
wQuickActions fyne.Window
eventManager *event.Manager
@@ -306,14 +327,15 @@ type menuHandler struct {
}
type newServiceClientArgs struct {
addr string
logFile string
app fyne.App
showSettings bool
showNetworks bool
showDebug bool
showLoginURL bool
showProfiles bool
addr string
logFile string
app fyne.App
showSettings bool
showNetworks bool
showDebug bool
showLoginURL bool
showProfiles bool
showQuickActions bool
}
// newServiceClient instance constructor
@@ -349,6 +371,8 @@ func newServiceClient(args *newServiceClientArgs) *serviceClient {
s.showDebugUI()
case args.showProfiles:
s.showProfilesUI()
case args.showQuickActions:
s.showQuickActionsUI()
}
return s
@@ -425,18 +449,22 @@ func (s *serviceClient) showSettingsUI() {
s.sDisableClientRoutes = widget.NewCheck("This peer won't route traffic to other peers", nil)
s.sDisableServerRoutes = widget.NewCheck("This peer won't act as router for others", nil)
s.sBlockLANAccess = widget.NewCheck("Blocks local network access when used as exit node", nil)
s.sEnableSSHRoot = widget.NewCheck("Enable SSH Root Login", nil)
s.sEnableSSHSFTP = widget.NewCheck("Enable SSH SFTP", nil)
s.sEnableSSHLocalPortForward = widget.NewCheck("Enable SSH Local Port Forwarding", nil)
s.sEnableSSHRemotePortForward = widget.NewCheck("Enable SSH Remote Port Forwarding", nil)
s.sDisableSSHAuth = widget.NewCheck("Disable SSH Authentication", nil)
s.iSSHJWTCacheTTL = widget.NewEntry()
s.wSettings.SetContent(s.getSettingsForm())
s.wSettings.Resize(fyne.NewSize(600, 500))
s.wSettings.Resize(fyne.NewSize(600, 400))
s.wSettings.SetFixedSize(true)
s.getSrvConfig()
s.wSettings.Show()
}
// getSettingsForm to embed it into settings window.
func (s *serviceClient) getSettingsForm() *widget.Form {
func (s *serviceClient) getConnectionForm() *widget.Form {
var activeProfName string
activeProf, err := s.profileManager.GetActiveProfile()
if err != nil {
@@ -447,153 +475,277 @@ func (s *serviceClient) getSettingsForm() *widget.Form {
return &widget.Form{
Items: []*widget.FormItem{
{Text: "Profile", Widget: widget.NewLabel(activeProfName)},
{Text: "Management URL", Widget: s.iMngURL},
{Text: "Pre-shared Key", Widget: s.iPreSharedKey},
{Text: "Quantum-Resistance", Widget: s.sRosenpassPermissive},
{Text: "Interface Name", Widget: s.iInterfaceName},
{Text: "Interface Port", Widget: s.iInterfacePort},
{Text: "MTU", Widget: s.iMTU},
{Text: "Management URL", Widget: s.iMngURL},
{Text: "Pre-shared Key", Widget: s.iPreSharedKey},
{Text: "Log File", Widget: s.iLogFile},
},
}
}
func (s *serviceClient) saveSettings() {
// Check if update settings are disabled by daemon
features, err := s.getFeatures()
if err != nil {
log.Errorf("failed to get features from daemon: %v", err)
// Continue with default behavior if features can't be retrieved
} else if features != nil && features.DisableUpdateSettings {
log.Warn("Configuration updates are disabled by daemon")
dialog.ShowError(fmt.Errorf("Configuration updates are disabled by daemon"), s.wSettings)
return
}
if err := s.validateSettings(); err != nil {
dialog.ShowError(err, s.wSettings)
return
}
port, mtu, err := s.parseNumericSettings()
if err != nil {
dialog.ShowError(err, s.wSettings)
return
}
iMngURL := strings.TrimSpace(s.iMngURL.Text)
if s.hasSettingsChanged(iMngURL, port, mtu) {
if err := s.applySettingsChanges(iMngURL, port, mtu); err != nil {
dialog.ShowError(err, s.wSettings)
return
}
}
s.wSettings.Close()
}
func (s *serviceClient) validateSettings() error {
if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey {
if _, err := wgtypes.ParseKey(s.iPreSharedKey.Text); err != nil {
return fmt.Errorf("Invalid Pre-shared Key Value")
}
}
return nil
}
func (s *serviceClient) parseNumericSettings() (int64, int64, error) {
port, err := strconv.ParseInt(s.iInterfacePort.Text, 10, 64)
if err != nil {
return 0, 0, errors.New("Invalid interface port")
}
if port < 1 || port > 65535 {
return 0, 0, errors.New("Invalid interface port: out of range 1-65535")
}
var mtu int64
mtuText := strings.TrimSpace(s.iMTU.Text)
if mtuText != "" {
mtu, err = strconv.ParseInt(mtuText, 10, 64)
if err != nil {
return 0, 0, errors.New("Invalid MTU value")
}
if mtu < iface.MinMTU || mtu > iface.MaxMTU {
return 0, 0, fmt.Errorf("MTU must be between %d and %d bytes", iface.MinMTU, iface.MaxMTU)
}
}
return port, mtu, nil
}
func (s *serviceClient) hasSettingsChanged(iMngURL string, port, mtu int64) bool {
return s.managementURL != iMngURL ||
s.preSharedKey != s.iPreSharedKey.Text ||
s.RosenpassPermissive != s.sRosenpassPermissive.Checked ||
s.interfaceName != s.iInterfaceName.Text ||
s.interfacePort != int(port) ||
s.mtu != uint16(mtu) ||
s.networkMonitor != s.sNetworkMonitor.Checked ||
s.disableDNS != s.sDisableDNS.Checked ||
s.disableClientRoutes != s.sDisableClientRoutes.Checked ||
s.disableServerRoutes != s.sDisableServerRoutes.Checked ||
s.blockLANAccess != s.sBlockLANAccess.Checked ||
s.hasSSHChanges()
}
func (s *serviceClient) applySettingsChanges(iMngURL string, port, mtu int64) error {
s.managementURL = iMngURL
s.preSharedKey = s.iPreSharedKey.Text
s.mtu = uint16(mtu)
req, err := s.buildSetConfigRequest(iMngURL, port, mtu)
if err != nil {
return fmt.Errorf("build config request: %w", err)
}
if err := s.sendConfigUpdate(req); err != nil {
return fmt.Errorf("set configuration: %w", err)
}
return nil
}
func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) (*proto.SetConfigRequest, error) {
currUser, err := user.Current()
if err != nil {
return nil, fmt.Errorf("get current user: %w", err)
}
activeProf, err := s.profileManager.GetActiveProfile()
if err != nil {
return nil, fmt.Errorf("get active profile: %w", err)
}
req := &proto.SetConfigRequest{
ProfileName: activeProf.Name,
Username: currUser.Username,
}
if iMngURL != "" {
req.ManagementUrl = iMngURL
}
req.RosenpassPermissive = &s.sRosenpassPermissive.Checked
req.InterfaceName = &s.iInterfaceName.Text
req.WireguardPort = &port
if mtu > 0 {
req.Mtu = &mtu
}
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
req.EnableSSHRoot = &s.sEnableSSHRoot.Checked
req.EnableSSHSFTP = &s.sEnableSSHSFTP.Checked
req.EnableSSHLocalPortForwarding = &s.sEnableSSHLocalPortForward.Checked
req.EnableSSHRemotePortForwarding = &s.sEnableSSHRemotePortForward.Checked
req.DisableSSHAuth = &s.sDisableSSHAuth.Checked
sshJWTCacheTTLText := strings.TrimSpace(s.iSSHJWTCacheTTL.Text)
if sshJWTCacheTTLText != "" {
sshJWTCacheTTL, err := strconv.ParseInt(sshJWTCacheTTLText, 10, 32)
if err != nil {
return nil, errors.New("Invalid SSH JWT Cache TTL value")
}
if sshJWTCacheTTL < 0 || sshJWTCacheTTL > maxSSHJWTCacheTTL {
return nil, fmt.Errorf("SSH JWT Cache TTL must be between 0 and %d seconds", maxSSHJWTCacheTTL)
}
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
req.SshJWTCacheTTL = &sshJWTCacheTTL32
}
if s.iPreSharedKey.Text != censoredPreSharedKey {
req.OptionalPreSharedKey = &s.iPreSharedKey.Text
}
return req, nil
}
func (s *serviceClient) sendConfigUpdate(req *proto.SetConfigRequest) error {
conn, err := s.getSrvClient(failFastTimeout)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
_, err = conn.SetConfig(s.ctx, req)
if err != nil {
return fmt.Errorf("set config: %w", err)
}
// Reconnect if connected to apply the new settings
go func() {
status, err := conn.Status(s.ctx, &proto.StatusRequest{})
if err != nil {
log.Errorf("get service status: %v", err)
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)
return
}
}
}()
return nil
}
func (s *serviceClient) getSettingsForm() fyne.CanvasObject {
connectionForm := s.getConnectionForm()
networkForm := s.getNetworkForm()
sshForm := s.getSSHForm()
tabs := container.NewAppTabs(
container.NewTabItem("Connection", connectionForm),
container.NewTabItem("Network", networkForm),
container.NewTabItem("SSH", sshForm),
)
saveButton := widget.NewButtonWithIcon("Save", theme.ConfirmIcon(), s.saveSettings)
saveButton.Importance = widget.HighImportance
cancelButton := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
s.wSettings.Close()
})
buttonContainer := container.NewHBox(
layout.NewSpacer(),
cancelButton,
saveButton,
)
return container.NewBorder(nil, buttonContainer, nil, nil, tabs)
}
func (s *serviceClient) getNetworkForm() *widget.Form {
return &widget.Form{
Items: []*widget.FormItem{
{Text: "Network Monitor", Widget: s.sNetworkMonitor},
{Text: "Disable DNS", Widget: s.sDisableDNS},
{Text: "Disable Client Routes", Widget: s.sDisableClientRoutes},
{Text: "Disable Server Routes", Widget: s.sDisableServerRoutes},
{Text: "Disable LAN Access", Widget: s.sBlockLANAccess},
},
SubmitText: "Save",
OnSubmit: func() {
// Check if update settings are disabled by daemon
features, err := s.getFeatures()
if err != nil {
log.Errorf("failed to get features from daemon: %v", err)
// Continue with default behavior if features can't be retrieved
} else if features != nil && features.DisableUpdateSettings {
log.Warn("Configuration updates are disabled by daemon")
dialog.ShowError(fmt.Errorf("Configuration updates are disabled by daemon"), s.wSettings)
return
}
}
}
if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey {
// validate preSharedKey if it added
if _, err := wgtypes.ParseKey(s.iPreSharedKey.Text); err != nil {
dialog.ShowError(fmt.Errorf("Invalid Pre-shared Key Value"), s.wSettings)
return
}
}
port, err := strconv.ParseInt(s.iInterfacePort.Text, 10, 64)
if err != nil {
dialog.ShowError(errors.New("Invalid interface port"), s.wSettings)
return
}
var mtu int64
mtuText := strings.TrimSpace(s.iMTU.Text)
if mtuText != "" {
var err error
mtu, err = strconv.ParseInt(mtuText, 10, 64)
if err != nil {
dialog.ShowError(errors.New("Invalid MTU value"), s.wSettings)
return
}
if mtu < iface.MinMTU || mtu > iface.MaxMTU {
dialog.ShowError(fmt.Errorf("MTU must be between %d and %d bytes", iface.MinMTU, iface.MaxMTU), s.wSettings)
return
}
}
iMngURL := strings.TrimSpace(s.iMngURL.Text)
defer s.wSettings.Close()
// Check if any settings have changed
if s.managementURL != iMngURL || s.preSharedKey != s.iPreSharedKey.Text ||
s.RosenpassPermissive != s.sRosenpassPermissive.Checked ||
s.interfaceName != s.iInterfaceName.Text || s.interfacePort != int(port) ||
s.mtu != uint16(mtu) ||
s.networkMonitor != s.sNetworkMonitor.Checked ||
s.disableDNS != s.sDisableDNS.Checked ||
s.disableClientRoutes != s.sDisableClientRoutes.Checked ||
s.disableServerRoutes != s.sDisableServerRoutes.Checked ||
s.blockLANAccess != s.sBlockLANAccess.Checked {
s.managementURL = iMngURL
s.preSharedKey = s.iPreSharedKey.Text
s.mtu = uint16(mtu)
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
if mtu > 0 {
req.Mtu = &mtu
}
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
}
go func() {
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() {
s.wSettings.Close()
func (s *serviceClient) getSSHForm() *widget.Form {
return &widget.Form{
Items: []*widget.FormItem{
{Text: "Enable SSH Root Login", Widget: s.sEnableSSHRoot},
{Text: "Enable SSH SFTP", Widget: s.sEnableSSHSFTP},
{Text: "Enable SSH Local Port Forwarding", Widget: s.sEnableSSHLocalPortForward},
{Text: "Enable SSH Remote Port Forwarding", Widget: s.sEnableSSHRemotePortForward},
{Text: "Disable SSH Authentication", Widget: s.sDisableSSHAuth},
{Text: "JWT Cache TTL (seconds, 0=disabled)", Widget: s.iSSHJWTCacheTTL},
},
}
}
func (s *serviceClient) hasSSHChanges() bool {
currentSSHJWTCacheTTL := s.sshJWTCacheTTL
if text := strings.TrimSpace(s.iSSHJWTCacheTTL.Text); text != "" {
val, err := strconv.Atoi(text)
if err != nil {
return true
}
currentSSHJWTCacheTTL = val
}
return s.enableSSHRoot != s.sEnableSSHRoot.Checked ||
s.enableSSHSFTP != s.sEnableSSHSFTP.Checked ||
s.enableSSHLocalPortForward != s.sEnableSSHLocalPortForward.Checked ||
s.enableSSHRemotePortForward != s.sEnableSSHRemotePortForward.Checked ||
s.disableSSHAuth != s.sDisableSSHAuth.Checked ||
s.sshJWTCacheTTL != currentSSHJWTCacheTTL
}
func (s *serviceClient) login(ctx context.Context, openURL bool) (*proto.LoginResponse, error) {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
@@ -1113,6 +1265,25 @@ func (s *serviceClient) getSrvConfig() {
s.disableServerRoutes = cfg.DisableServerRoutes
s.blockLANAccess = cfg.BlockLANAccess
if cfg.EnableSSHRoot != nil {
s.enableSSHRoot = *cfg.EnableSSHRoot
}
if cfg.EnableSSHSFTP != nil {
s.enableSSHSFTP = *cfg.EnableSSHSFTP
}
if cfg.EnableSSHLocalPortForwarding != nil {
s.enableSSHLocalPortForward = *cfg.EnableSSHLocalPortForwarding
}
if cfg.EnableSSHRemotePortForwarding != nil {
s.enableSSHRemotePortForward = *cfg.EnableSSHRemotePortForwarding
}
if cfg.DisableSSHAuth != nil {
s.disableSSHAuth = *cfg.DisableSSHAuth
}
if cfg.SSHJWTCacheTTL != nil {
s.sshJWTCacheTTL = *cfg.SSHJWTCacheTTL
}
if s.showAdvancedSettings {
s.iMngURL.SetText(s.managementURL)
s.iPreSharedKey.SetText(cfg.PreSharedKey)
@@ -1133,6 +1304,24 @@ func (s *serviceClient) getSrvConfig() {
s.sDisableClientRoutes.SetChecked(cfg.DisableClientRoutes)
s.sDisableServerRoutes.SetChecked(cfg.DisableServerRoutes)
s.sBlockLANAccess.SetChecked(cfg.BlockLANAccess)
if cfg.EnableSSHRoot != nil {
s.sEnableSSHRoot.SetChecked(*cfg.EnableSSHRoot)
}
if cfg.EnableSSHSFTP != nil {
s.sEnableSSHSFTP.SetChecked(*cfg.EnableSSHSFTP)
}
if cfg.EnableSSHLocalPortForwarding != nil {
s.sEnableSSHLocalPortForward.SetChecked(*cfg.EnableSSHLocalPortForwarding)
}
if cfg.EnableSSHRemotePortForwarding != nil {
s.sEnableSSHRemotePortForward.SetChecked(*cfg.EnableSSHRemotePortForwarding)
}
if cfg.DisableSSHAuth != nil {
s.sDisableSSHAuth.SetChecked(*cfg.DisableSSHAuth)
}
if cfg.SSHJWTCacheTTL != nil {
s.iSSHJWTCacheTTL.SetText(strconv.Itoa(*cfg.SSHJWTCacheTTL))
}
}
if s.mNotifications == nil {
@@ -1203,6 +1392,15 @@ func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config {
config.DisableServerRoutes = cfg.DisableServerRoutes
config.BlockLANAccess = cfg.BlockLanAccess
config.EnableSSHRoot = &cfg.EnableSSHRoot
config.EnableSSHSFTP = &cfg.EnableSSHSFTP
config.EnableSSHLocalPortForwarding = &cfg.EnableSSHLocalPortForwarding
config.EnableSSHRemotePortForwarding = &cfg.EnableSSHRemotePortForwarding
config.DisableSSHAuth = &cfg.DisableSSHAuth
ttl := int(cfg.SshJWTCacheTTL)
config.SSHJWTCacheTTL = &ttl
return &config
}
+4 -4
View File
@@ -462,7 +462,7 @@ func (s *serviceClient) createDebugBundleFromCollection(
if uploadFailureReason != "" {
showUploadFailedDialog(progress.window, localPath, uploadFailureReason)
} else {
showUploadSuccessDialog(progress.window, localPath, uploadedKey)
showUploadSuccessDialog(s.app, progress.window, localPath, uploadedKey)
}
} else {
showBundleCreatedDialog(progress.window, localPath)
@@ -527,7 +527,7 @@ func (s *serviceClient) handleDebugCreation(
if uploadFailureReason != "" {
showUploadFailedDialog(w, localPath, uploadFailureReason)
} else {
showUploadSuccessDialog(w, localPath, uploadedKey)
showUploadSuccessDialog(s.app, w, localPath, uploadedKey)
}
} else {
showBundleCreatedDialog(w, localPath)
@@ -609,7 +609,7 @@ func showUploadFailedDialog(w fyne.Window, localPath, failureReason string) {
}
// showUploadSuccessDialog displays a dialog when upload succeeds
func showUploadSuccessDialog(w fyne.Window, localPath, uploadedKey string) {
func showUploadSuccessDialog(a fyne.App, w fyne.Window, localPath, uploadedKey string) {
log.Infof("Upload key: %s", uploadedKey)
keyEntry := widget.NewEntry()
keyEntry.SetText(uploadedKey)
@@ -627,7 +627,7 @@ func showUploadSuccessDialog(w fyne.Window, localPath, uploadedKey string) {
customDialog := dialog.NewCustom("Upload Successful", "OK", content, w)
copyBtn := createButtonWithAction("Copy key", func() {
w.Clipboard().SetContent(uploadedKey)
a.Clipboard().SetContent(uploadedKey)
log.Info("Upload key copied to clipboard")
})
+3
View File
@@ -9,6 +9,9 @@ import (
//go:embed assets/netbird.png
var iconAbout []byte
//go:embed assets/netbird-disconnected.png
var iconAboutDisconnected []byte
//go:embed assets/netbird-systemtray-connected.png
var iconConnected []byte
+3
View File
@@ -7,6 +7,9 @@ import (
//go:embed assets/netbird.ico
var iconAbout []byte
//go:embed assets/netbird-disconnected.ico
var iconAboutDisconnected []byte
//go:embed assets/netbird-systemtray-connected.ico
var iconConnected []byte
+349
View File
@@ -0,0 +1,349 @@
//go:build !(linux && 386)
//go:generate fyne bundle -o quickactions_assets.go assets/connected.png
//go:generate fyne bundle -o quickactions_assets.go -append assets/disconnected.png
package main
import (
"context"
_ "embed"
"fmt"
"runtime"
"sync/atomic"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
)
type quickActionsUiState struct {
connectionStatus string
isToggleButtonEnabled bool
isConnectionChanged bool
toggleAction func()
}
func newQuickActionsUiState() quickActionsUiState {
return quickActionsUiState{
connectionStatus: string(internal.StatusIdle),
isToggleButtonEnabled: false,
isConnectionChanged: false,
}
}
type clientConnectionStatusProvider interface {
connectionStatus(ctx context.Context) (string, error)
}
type daemonClientConnectionStatusProvider struct {
client proto.DaemonServiceClient
}
func (d daemonClientConnectionStatusProvider) connectionStatus(ctx context.Context) (string, error) {
childCtx, cancel := context.WithTimeout(ctx, 400*time.Millisecond)
defer cancel()
status, err := d.client.Status(childCtx, &proto.StatusRequest{})
if err != nil {
return "", err
}
return status.Status, nil
}
type clientCommand interface {
execute() error
}
type connectCommand struct {
connectClient func() error
}
func (c connectCommand) execute() error {
return c.connectClient()
}
type disconnectCommand struct {
disconnectClient func() error
}
func (c disconnectCommand) execute() error {
return c.disconnectClient()
}
type quickActionsViewModel struct {
provider clientConnectionStatusProvider
connect clientCommand
disconnect clientCommand
uiChan chan quickActionsUiState
isWatchingConnectionStatus atomic.Bool
}
func newQuickActionsViewModel(ctx context.Context, provider clientConnectionStatusProvider, connect, disconnect clientCommand, uiChan chan quickActionsUiState) {
viewModel := quickActionsViewModel{
provider: provider,
connect: connect,
disconnect: disconnect,
uiChan: uiChan,
}
viewModel.isWatchingConnectionStatus.Store(true)
// base UI status
uiChan <- newQuickActionsUiState()
// this retrieves the current connection status
// and pushes the UI state that reflects it via uiChan
go viewModel.watchConnectionStatus(ctx)
}
func (q *quickActionsViewModel) updateUiState(ctx context.Context) {
uiState := newQuickActionsUiState()
connectionStatus, err := q.provider.connectionStatus(ctx)
if err != nil {
log.Errorf("Status: Error - %v", err)
q.uiChan <- uiState
return
}
if connectionStatus == string(internal.StatusConnected) {
uiState.toggleAction = func() {
q.executeCommand(q.disconnect)
}
} else {
uiState.toggleAction = func() {
q.executeCommand(q.connect)
}
}
uiState.isToggleButtonEnabled = true
uiState.connectionStatus = connectionStatus
q.uiChan <- uiState
}
func (q *quickActionsViewModel) watchConnectionStatus(ctx context.Context) {
ticker := time.NewTicker(1000 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if q.isWatchingConnectionStatus.Load() {
q.updateUiState(ctx)
}
}
}
}
func (q *quickActionsViewModel) executeCommand(command clientCommand) {
uiState := newQuickActionsUiState()
// newQuickActionsUiState starts with Idle connection status,
// and all that's necessary here is to just disable the toggle button.
uiState.connectionStatus = ""
q.uiChan <- uiState
q.isWatchingConnectionStatus.Store(false)
err := command.execute()
if err != nil {
log.Errorf("Status: Error - %v", err)
q.isWatchingConnectionStatus.Store(true)
} else {
uiState = newQuickActionsUiState()
uiState.isConnectionChanged = true
q.uiChan <- uiState
}
}
func getSystemTrayName() string {
os := runtime.GOOS
switch os {
case "darwin":
return "menu bar"
default:
return "system tray"
}
}
func (s *serviceClient) getNetBirdImage(name string, content []byte) *canvas.Image {
imageSize := fyne.NewSize(64, 64)
resource := fyne.NewStaticResource(name, content)
image := canvas.NewImageFromResource(resource)
image.FillMode = canvas.ImageFillContain
image.SetMinSize(imageSize)
image.Resize(imageSize)
return image
}
type quickActionsUiComponents struct {
content *fyne.Container
toggleConnectionButton *widget.Button
connectedLabelText, disconnectedLabelText string
connectedImage, disconnectedImage *canvas.Image
connectedCircleRes, disconnectedCircleRes fyne.Resource
}
// applyQuickActionsUiState applies a single UI state to the quick actions window.
// It closes the window and returns true if the connection status has changed,
// in which case the caller should stop processing further states.
func (s *serviceClient) applyQuickActionsUiState(
uiState quickActionsUiState,
components quickActionsUiComponents,
) bool {
if uiState.isConnectionChanged {
fyne.DoAndWait(func() {
s.wQuickActions.Close()
})
return true
}
var logo *canvas.Image
var buttonText string
var buttonIcon fyne.Resource
if uiState.connectionStatus == string(internal.StatusConnected) {
buttonText = components.connectedLabelText
buttonIcon = components.connectedCircleRes
logo = components.connectedImage
} else if uiState.connectionStatus == string(internal.StatusIdle) {
buttonText = components.disconnectedLabelText
buttonIcon = components.disconnectedCircleRes
logo = components.disconnectedImage
}
fyne.DoAndWait(func() {
if buttonText != "" {
components.toggleConnectionButton.SetText(buttonText)
}
if buttonIcon != nil {
components.toggleConnectionButton.SetIcon(buttonIcon)
}
if uiState.isToggleButtonEnabled {
components.toggleConnectionButton.Enable()
} else {
components.toggleConnectionButton.Disable()
}
components.toggleConnectionButton.OnTapped = func() {
if uiState.toggleAction != nil {
go uiState.toggleAction()
}
}
components.toggleConnectionButton.Refresh()
// the second position in the content's object array is the NetBird logo.
if logo != nil {
components.content.Objects[1] = logo
components.content.Refresh()
}
})
return false
}
// showQuickActionsUI displays a simple window with the NetBird logo and a connection toggle button.
func (s *serviceClient) showQuickActionsUI() {
s.wQuickActions = s.app.NewWindow("NetBird")
vmCtx, vmCancel := context.WithCancel(s.ctx)
s.wQuickActions.SetOnClosed(vmCancel)
client, err := s.getSrvClient(defaultFailTimeout)
connCmd := connectCommand{
connectClient: func() error {
return s.menuUpClick(s.ctx)
},
}
disConnCmd := disconnectCommand{
disconnectClient: func() error {
return s.menuDownClick()
},
}
if err != nil {
log.Errorf("get service client: %v", err)
return
}
uiChan := make(chan quickActionsUiState, 1)
newQuickActionsViewModel(vmCtx, daemonClientConnectionStatusProvider{client: client}, connCmd, disConnCmd, uiChan)
connectedImage := s.getNetBirdImage("netbird.png", iconAbout)
disconnectedImage := s.getNetBirdImage("netbird-disconnected.png", iconAboutDisconnected)
connectedCircle := canvas.NewImageFromResource(resourceConnectedPng)
disconnectedCircle := canvas.NewImageFromResource(resourceDisconnectedPng)
connectedLabelText := "Disconnect"
disconnectedLabelText := "Connect"
toggleConnectionButton := widget.NewButtonWithIcon(disconnectedLabelText, disconnectedCircle.Resource, func() {
// This button's tap function will be set when an ui state arrives via the uiChan channel.
})
// Button starts disabled until the first ui state arrives.
toggleConnectionButton.Disable()
hintLabelText := fmt.Sprintf("You can always access NetBird from your %s.", getSystemTrayName())
hintLabel := widget.NewLabel(hintLabelText)
content := container.NewVBox(
layout.NewSpacer(),
disconnectedImage,
layout.NewSpacer(),
container.NewCenter(toggleConnectionButton),
layout.NewSpacer(),
container.NewCenter(hintLabel),
)
// this watches for ui state updates.
go func() {
for {
select {
case <-vmCtx.Done():
return
case uiState, ok := <-uiChan:
if !ok {
return
}
closed := s.applyQuickActionsUiState(
uiState,
quickActionsUiComponents{
content,
toggleConnectionButton,
connectedLabelText, disconnectedLabelText,
connectedImage, disconnectedImage,
connectedCircle.Resource, disconnectedCircle.Resource,
},
)
if closed {
return
}
}
}
}()
s.wQuickActions.SetContent(content)
s.wQuickActions.Resize(fyne.NewSize(400, 200))
s.wQuickActions.SetFixedSize(true)
s.wQuickActions.Show()
}
+23
View File
@@ -0,0 +1,23 @@
// auto-generated
// Code generated by '$ fyne bundle'. DO NOT EDIT.
package main
import (
_ "embed"
"fyne.io/fyne/v2"
)
//go:embed assets/connected.png
var resourceConnectedPngData []byte
var resourceConnectedPng = &fyne.StaticResource{
StaticName: "assets/connected.png",
StaticContent: resourceConnectedPngData,
}
//go:embed assets/disconnected.png
var resourceDisconnectedPngData []byte
var resourceDisconnectedPng = &fyne.StaticResource{
StaticName: "assets/disconnected.png",
StaticContent: resourceDisconnectedPngData,
}
+76
View File
@@ -0,0 +1,76 @@
//go:build !windows && !(linux && 386)
package main
import (
"context"
"os"
"os/exec"
"os/signal"
"syscall"
log "github.com/sirupsen/logrus"
)
// setupSignalHandler sets up a signal handler to listen for SIGUSR1.
// When received, it opens the quick actions window.
func (s *serviceClient) setupSignalHandler(ctx context.Context) {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGUSR1)
go func() {
for {
select {
case <-ctx.Done():
return
case <-sigChan:
log.Info("received SIGUSR1 signal, opening quick actions window")
s.openQuickActions()
}
}
}()
}
// openQuickActions opens the quick actions window by spawning a new process.
func (s *serviceClient) openQuickActions() {
proc, err := os.Executable()
if err != nil {
log.Errorf("get executable path: %v", err)
return
}
cmd := exec.CommandContext(s.ctx, proc,
"--quick-actions=true",
"--daemon-addr="+s.addr,
)
if out := s.attachOutput(cmd); out != nil {
defer func() {
if err := out.Close(); err != nil {
log.Errorf("close log file %s: %v", s.logFile, err)
}
}()
}
log.Infof("running command: %s --quick-actions=true --daemon-addr=%s", proc, s.addr)
if err := cmd.Start(); err != nil {
log.Errorf("start quick actions window: %v", err)
return
}
go func() {
if err := cmd.Wait(); err != nil {
log.Debugf("quick actions window exited: %v", err)
}
}()
}
// sendShowWindowSignal sends SIGUSR1 to the specified PID.
func sendShowWindowSignal(pid int32) error {
process, err := os.FindProcess(int(pid))
if err != nil {
return err
}
return process.Signal(syscall.SIGUSR1)
}
+171
View File
@@ -0,0 +1,171 @@
//go:build windows
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
const (
quickActionsTriggerEventName = `Global\NetBirdQuickActionsTriggerEvent`
waitTimeout = 5 * time.Second
// SYNCHRONIZE is needed for WaitForSingleObject, EVENT_MODIFY_STATE for ResetEvent.
desiredAccesses = windows.SYNCHRONIZE | windows.EVENT_MODIFY_STATE
)
func getEventNameUint16Pointer() (*uint16, error) {
eventNamePtr, err := windows.UTF16PtrFromString(quickActionsTriggerEventName)
if err != nil {
log.Errorf("Failed to convert event name '%s' to UTF16: %v", quickActionsTriggerEventName, err)
return nil, err
}
return eventNamePtr, nil
}
// setupSignalHandler sets up signal handling for Windows.
// Windows doesn't support SIGUSR1, so this uses a similar approach using windows.Events.
func (s *serviceClient) setupSignalHandler(ctx context.Context) {
eventNamePtr, err := getEventNameUint16Pointer()
if err != nil {
return
}
eventHandle, err := windows.CreateEvent(nil, 1, 0, eventNamePtr)
if err != nil {
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
log.Warnf("Quick actions trigger event '%s' already exists. Attempting to open.", quickActionsTriggerEventName)
eventHandle, err = windows.OpenEvent(desiredAccesses, false, eventNamePtr)
if err != nil {
log.Errorf("Failed to open existing quick actions trigger event '%s': %v", quickActionsTriggerEventName, err)
return
}
log.Infof("Successfully opened existing quick actions trigger event '%s'.", quickActionsTriggerEventName)
} else {
log.Errorf("Failed to create quick actions trigger event '%s': %v", quickActionsTriggerEventName, err)
return
}
}
if eventHandle == windows.InvalidHandle {
log.Errorf("Obtained an invalid handle for quick actions trigger event '%s'", quickActionsTriggerEventName)
return
}
log.Infof("Quick actions handler waiting for signal on event: %s", quickActionsTriggerEventName)
go s.waitForEvent(ctx, eventHandle)
}
func (s *serviceClient) waitForEvent(ctx context.Context, eventHandle windows.Handle) {
defer func() {
if err := windows.CloseHandle(eventHandle); err != nil {
log.Errorf("Failed to close quick actions event handle '%s': %v", quickActionsTriggerEventName, err)
}
}()
for {
if ctx.Err() != nil {
return
}
status, err := windows.WaitForSingleObject(eventHandle, uint32(waitTimeout.Milliseconds()))
switch status {
case windows.WAIT_OBJECT_0:
log.Info("Received signal on quick actions event. Opening quick actions window.")
// reset the event so it can be triggered again later (manual reset == 1)
if err := windows.ResetEvent(eventHandle); err != nil {
log.Errorf("Failed to reset quick actions event '%s': %v", quickActionsTriggerEventName, err)
}
s.openQuickActions()
case uint32(windows.WAIT_TIMEOUT):
default:
if isDone := logUnexpectedStatus(ctx, status, err); isDone {
return
}
}
}
}
func logUnexpectedStatus(ctx context.Context, status uint32, err error) bool {
log.Errorf("Unexpected status %d from WaitForSingleObject for quick actions event '%s': %v",
status, quickActionsTriggerEventName, err)
select {
case <-time.After(5 * time.Second):
return false
case <-ctx.Done():
return true
}
}
// openQuickActions opens the quick actions window by spawning a new process.
func (s *serviceClient) openQuickActions() {
proc, err := os.Executable()
if err != nil {
log.Errorf("get executable path: %v", err)
return
}
cmd := exec.CommandContext(s.ctx, proc,
"--quick-actions=true",
"--daemon-addr="+s.addr,
)
if out := s.attachOutput(cmd); out != nil {
defer func() {
if err := out.Close(); err != nil {
log.Errorf("close log file %s: %v", s.logFile, err)
}
}()
}
log.Infof("running command: %s --quick-actions=true --daemon-addr=%s", proc, s.addr)
if err := cmd.Start(); err != nil {
log.Errorf("error starting quick actions window: %v", err)
return
}
go func() {
if err := cmd.Wait(); err != nil {
log.Debugf("quick actions window exited: %v", err)
}
}()
}
func sendShowWindowSignal(pid int32) error {
_, err := os.FindProcess(int(pid))
if err != nil {
return err
}
eventNamePtr, err := getEventNameUint16Pointer()
if err != nil {
return err
}
eventHandle, err := windows.OpenEvent(desiredAccesses, false, eventNamePtr)
if err != nil {
return err
}
err = windows.SetEvent(eventHandle)
if err != nil {
return fmt.Errorf("Error setting event: %w", err)
}
return nil
}