//go:build !(linux && 386) package main import ( "context" "fmt" "runtime" "sort" "strings" "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/proto" ) const ( allNetworksText = "All networks" overlappingNetworksText = "Overlapping networks" exitNodeNetworksText = "Exit-node networks" allNetworks filter = "all" overlappingNetworks filter = "overlapping" exitNodeNetworks filter = "exit-node" getClientFMT = "get client: %v" ) type filter string func (s *serviceClient) showNetworksUI() { s.wRoutes = s.app.NewWindow("Networks") allGrid := container.New(layout.NewGridLayout(3)) go s.updateNetworks(allGrid, allNetworks) overlappingGrid := container.New(layout.NewGridLayout(3)) exitNodeGrid := container.New(layout.NewGridLayout(3)) routeCheckContainer := container.NewVBox() tabs := container.NewAppTabs( container.NewTabItem(allNetworksText, allGrid), container.NewTabItem(overlappingNetworksText, overlappingGrid), container.NewTabItem(exitNodeNetworksText, exitNodeGrid), ) tabs.OnSelected = func(item *container.TabItem) { s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) } tabs.OnUnselected = func(item *container.TabItem) { grid, _ := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) grid.Objects = nil } routeCheckContainer.Add(tabs) scrollContainer := container.NewVScroll(routeCheckContainer) scrollContainer.SetMinSize(fyne.NewSize(200, 300)) buttonBox := container.NewHBox( layout.NewSpacer(), widget.NewButton("Refresh", func() { s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) }), widget.NewButton("Select all", func() { _, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) s.selectAllFilteredNetworks(f) s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) }), widget.NewButton("Deselect All", func() { _, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) s.deselectAllFilteredNetworks(f) s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) }), layout.NewSpacer(), ) content := container.NewBorder(nil, buttonBox, nil, nil, scrollContainer) s.wRoutes.SetContent(content) s.wRoutes.Show() s.startAutoRefresh(10*time.Second, tabs, allGrid, overlappingGrid, exitNodeGrid) } func (s *serviceClient) updateNetworks(grid *fyne.Container, f filter) { grid.Objects = nil grid.Refresh() idHeader := widget.NewLabelWithStyle(" ID", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) networkHeader := widget.NewLabelWithStyle("Range/Domains", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) resolvedIPsHeader := widget.NewLabelWithStyle("Resolved IPs", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) grid.Add(idHeader) grid.Add(networkHeader) grid.Add(resolvedIPsHeader) filteredRoutes, err := s.getFilteredNetworks(f) if err != nil { return } sortNetworksByIDs(filteredRoutes) for _, route := range filteredRoutes { r := route checkBox := widget.NewCheck(r.GetID(), func(checked bool) { s.selectNetwork(r.ID, checked) }) checkBox.Checked = route.Selected checkBox.Resize(fyne.NewSize(20, 20)) checkBox.Refresh() grid.Add(checkBox) network := r.GetRange() domains := r.GetDomains() if len(domains) == 0 { grid.Add(widget.NewLabel(network)) grid.Add(widget.NewLabel("")) continue } // our selectors are only for display noopFunc := func(_ string) { // do nothing } domainsSelector := widget.NewSelect(domains, noopFunc) domainsSelector.Selected = domains[0] grid.Add(domainsSelector) var resolvedIPsList []string for domain, ipList := range r.GetResolvedIPs() { resolvedIPsList = append(resolvedIPsList, fmt.Sprintf("%s: %s", domain, strings.Join(ipList.GetIps(), ", "))) } if len(resolvedIPsList) == 0 { grid.Add(widget.NewLabel("")) continue } // TODO: limit width within the selector display resolvedIPsSelector := widget.NewSelect(resolvedIPsList, noopFunc) resolvedIPsSelector.Selected = resolvedIPsList[0] resolvedIPsSelector.Resize(fyne.NewSize(100, 100)) grid.Add(resolvedIPsSelector) } s.wRoutes.Content().Refresh() grid.Refresh() } func (s *serviceClient) getFilteredNetworks(f filter) ([]*proto.Network, error) { routes, err := s.fetchNetworks() if err != nil { log.Errorf(getClientFMT, err) s.showError(fmt.Errorf(getClientFMT, err)) return nil, err } switch f { case overlappingNetworks: return getOverlappingNetworks(routes), nil case exitNodeNetworks: return getExitNodeNetworks(routes), nil default: } return routes, nil } func getOverlappingNetworks(routes []*proto.Network) []*proto.Network { var filteredRoutes []*proto.Network existingRange := make(map[string][]*proto.Network) for _, route := range routes { if len(route.Domains) > 0 { continue } if r, exists := existingRange[route.GetRange()]; exists { r = append(r, route) existingRange[route.GetRange()] = r } else { existingRange[route.GetRange()] = []*proto.Network{route} } } for _, r := range existingRange { if len(r) > 1 { filteredRoutes = append(filteredRoutes, r...) } } return filteredRoutes } func getExitNodeNetworks(routes []*proto.Network) []*proto.Network { var filteredRoutes []*proto.Network for _, route := range routes { if route.Range == "0.0.0.0/0" { filteredRoutes = append(filteredRoutes, route) } } return filteredRoutes } func sortNetworksByIDs(routes []*proto.Network) { sort.Slice(routes, func(i, j int) bool { return strings.ToLower(routes[i].GetID()) < strings.ToLower(routes[j].GetID()) }) } func (s *serviceClient) fetchNetworks() ([]*proto.Network, error) { conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { return nil, fmt.Errorf(getClientFMT, err) } resp, err := conn.ListNetworks(s.ctx, &proto.ListNetworksRequest{}) if err != nil { return nil, fmt.Errorf("failed to list routes: %v", err) } return resp.Routes, nil } func (s *serviceClient) selectNetwork(id string, checked bool) { conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { log.Errorf(getClientFMT, err) s.showError(fmt.Errorf(getClientFMT, err)) return } req := &proto.SelectNetworksRequest{ NetworkIDs: []string{id}, Append: checked, } if checked { if _, err := conn.SelectNetworks(s.ctx, req); err != nil { log.Errorf("failed to select network: %v", err) s.showError(fmt.Errorf("failed to select network: %v", err)) return } log.Infof("Network '%s' selected", id) } else { if _, err := conn.DeselectNetworks(s.ctx, req); err != nil { log.Errorf("failed to deselect network: %v", err) s.showError(fmt.Errorf("failed to deselect network: %v", err)) return } log.Infof("Network '%s' deselected", id) } } func (s *serviceClient) selectAllFilteredNetworks(f filter) { conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { log.Errorf(getClientFMT, err) return } req := s.getNetworksRequest(f, true) if _, err := conn.SelectNetworks(s.ctx, req); err != nil { log.Errorf("failed to select all networks: %v", err) s.showError(fmt.Errorf("failed to select all networks: %v", err)) return } log.Debug("All networks selected") } func (s *serviceClient) deselectAllFilteredNetworks(f filter) { conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { log.Errorf(getClientFMT, err) return } req := s.getNetworksRequest(f, false) if _, err := conn.DeselectNetworks(s.ctx, req); err != nil { log.Errorf("failed to deselect all networks: %v", err) s.showError(fmt.Errorf("failed to deselect all networks: %v", err)) return } log.Debug("All networks deselected") } func (s *serviceClient) getNetworksRequest(f filter, appendRoute bool) *proto.SelectNetworksRequest { req := &proto.SelectNetworksRequest{} if f == allNetworks { req.All = true } else { routes, err := s.getFilteredNetworks(f) if err != nil { return nil } for _, route := range routes { req.NetworkIDs = append(req.NetworkIDs, route.GetID()) } req.Append = appendRoute } return req } func (s *serviceClient) showError(err error) { wrappedMessage := wrapText(err.Error(), 50) dialog.ShowError(fmt.Errorf("%s", wrappedMessage), s.wRoutes) } func (s *serviceClient) startAutoRefresh(interval time.Duration, tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) { ticker := time.NewTicker(interval) go func() { for range ticker.C { s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodesGrid) } }() s.wRoutes.SetOnClosed(func() { ticker.Stop() }) } func (s *serviceClient) updateNetworksBasedOnDisplayTab(tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) { grid, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodesGrid) s.wRoutes.Content().Refresh() s.updateNetworks(grid, f) } func (s *serviceClient) updateExitNodes() { conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { log.Errorf("get client: %v", err) return } exitNodes, err := s.getExitNodes(conn) if err != nil { log.Errorf("get exit nodes: %v", err) return } s.exitNodeMu.Lock() defer s.exitNodeMu.Unlock() s.recreateExitNodeMenu(exitNodes) if len(s.mExitNodeItems) > 0 { s.mExitNode.Enable() } else { s.mExitNode.Disable() } log.Debugf("Exit nodes updated: %d", len(s.mExitNodeItems)) } func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) { for _, node := range s.mExitNodeItems { node.cancel() node.Remove() } s.mExitNodeItems = nil if runtime.GOOS == "linux" || runtime.GOOS == "freebsd" { s.mExitNode.Remove() s.mExitNode = systray.AddMenuItem("Exit Node", "Select exit node for routing traffic") } for _, node := range exitNodes { menuItem := s.mExitNode.AddSubMenuItemCheckbox( node.ID, fmt.Sprintf("Use exit node %s", node.ID), node.Selected, ) ctx, cancel := context.WithCancel(context.Background()) s.mExitNodeItems = append(s.mExitNodeItems, menuHandler{ MenuItem: menuItem, cancel: cancel, }) go s.handleChecked(ctx, node.ID, menuItem) } } func (s *serviceClient) getExitNodes(conn proto.DaemonServiceClient) ([]*proto.Network, error) { ctx, cancel := context.WithTimeout(s.ctx, defaultFailTimeout) defer cancel() resp, err := conn.ListNetworks(ctx, &proto.ListNetworksRequest{}) if err != nil { return nil, fmt.Errorf("list networks: %v", err) } var exitNodes []*proto.Network for _, network := range resp.Routes { if network.Range == "0.0.0.0/0" { exitNodes = append(exitNodes, network) } } return exitNodes, nil } func (s *serviceClient) handleChecked(ctx context.Context, id string, item *systray.MenuItem) { for { select { case <-ctx.Done(): return case _, ok := <-item.ClickedCh: if !ok { return } if err := s.toggleExitNode(id, item); err != nil { log.Errorf("failed to toggle exit node: %v", err) continue } } } } // Add function to toggle exit node selection func (s *serviceClient) toggleExitNode(nodeID string, item *systray.MenuItem) error { conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { return fmt.Errorf("get client: %v", err) } log.Infof("Toggling exit node '%s'", nodeID) s.exitNodeMu.Lock() defer s.exitNodeMu.Unlock() exitNodes, err := s.getExitNodes(conn) if err != nil { return fmt.Errorf("get exit nodes: %v", err) } var exitNode *proto.Network // find other selected nodes and ours ids := make([]string, 0, len(exitNodes)) for _, node := range exitNodes { if node.ID == nodeID { // preserve original state cp := *node //nolint:govet exitNode = &cp // set desired state for recreation node.Selected = true continue } if node.Selected { ids = append(ids, node.ID) // set desired state for recreation node.Selected = false } } if item.Checked() && len(ids) == 0 { // exit node is the only selected node, deselect it ids = append(ids, nodeID) exitNode = nil } // deselect all other selected exit nodes if err := s.deselectOtherExitNodes(conn, ids, item); err != nil { return err } if err := s.selectNewExitNode(conn, exitNode, nodeID, item); err != nil { return err } // linux/bsd doesn't handle Check/Uncheck well, so we recreate the menu if runtime.GOOS == "linux" || runtime.GOOS == "freebsd" { s.recreateExitNodeMenu(exitNodes) } return nil } func (s *serviceClient) deselectOtherExitNodes(conn proto.DaemonServiceClient, ids []string, currentItem *systray.MenuItem) error { // deselect all other selected exit nodes if len(ids) > 0 { deselectReq := &proto.SelectNetworksRequest{ NetworkIDs: ids, } if _, err := conn.DeselectNetworks(s.ctx, deselectReq); err != nil { return fmt.Errorf("deselect networks: %v", err) } log.Infof("Deselected exit nodes: %v", ids) } // uncheck all other exit node menu items for _, i := range s.mExitNodeItems { if i.MenuItem == currentItem { continue } i.Uncheck() log.Infof("Unchecked exit node %v", i) } return nil } func (s *serviceClient) selectNewExitNode(conn proto.DaemonServiceClient, exitNode *proto.Network, nodeID string, item *systray.MenuItem) error { if exitNode != nil && !exitNode.Selected { selectReq := &proto.SelectNetworksRequest{ NetworkIDs: []string{exitNode.ID}, Append: true, } if _, err := conn.SelectNetworks(s.ctx, selectReq); err != nil { return fmt.Errorf("select network: %v", err) } log.Infof("Selected exit node '%s'", nodeID) } item.Check() return nil } func getGridAndFilterFromTab(tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) (*fyne.Container, filter) { switch tabs.Selected().Text { case overlappingNetworksText: return overlappingGrid, overlappingNetworks case exitNodeNetworksText: return exitNodesGrid, exitNodeNetworks default: return allGrid, allNetworks } } // wrapText inserts newlines into the text to ensure that each line is // no longer than 'lineLength' runes. func wrapText(text string, lineLength int) string { var sb strings.Builder var currentLineLength int for _, runeValue := range text { sb.WriteRune(runeValue) currentLineLength++ if currentLineLength >= lineLength || runeValue == '\n' { sb.WriteRune('\n') currentLineLength = 0 } } return sb.String() }