diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index e06c0e6aa..966da1a50 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -7,6 +7,7 @@ import ( "net/netip" "slices" "sync" + "sync/atomic" "time" "github.com/google/uuid" @@ -190,21 +191,21 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState { // every private-service request) don't contend against each other. // Pure read methods take RLock; anything that mutates state takes Lock. type Status struct { - mux sync.RWMutex - peers map[string]State - changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription - signalState bool - signalError error - managementState bool - managementError error - relayStates []relay.ProbeResult - localPeer LocalPeerState - offlinePeers []State - mgmAddress string - signalAddress string - notifier *notifier - rosenpassEnabled bool - rosenpassPermissive bool + mux sync.RWMutex + peers map[string]State + changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription + signalState bool + signalError error + managementState bool + managementError error + relayStates []relay.ProbeResult + localPeer LocalPeerState + offlinePeers []State + mgmAddress string + signalAddress string + notifier *notifier + rosenpassEnabled bool + rosenpassPermissive bool // sessionExpiresAt is the absolute UTC instant at which the peer's SSO // session expires. Zero when the peer is not SSO-tracked or login // expiration is disabled. Populated from management LoginResponse / @@ -234,6 +235,13 @@ type Status struct { stateChangeMux sync.Mutex stateChangeStreams map[string]chan struct{} + // networksRevision bumps whenever the routed-networks set or their + // selected state changes (driven by the route manager). Surfaced in the + // status snapshot so the UI can fingerprint on it and re-fetch + // ListNetworks only on a real change. Atomic so the snapshot builder can + // read it without taking mux. + networksRevision atomic.Uint64 + ingressGwMgr *ingressgw.Manager routeIDLookup routeIDLookup @@ -1355,6 +1363,23 @@ func (d *Status) NotifyStateChange() { d.notifyStateChange() } +// BumpNetworksRevision increments the routed-networks revision and wakes every +// SubscribeStatus subscriber. The route manager calls it when a network map +// changes the available routes or when a selection is applied — the peer +// status itself only records actively-routed (chosen) networks, so without +// this bump a candidate route appearing/disappearing would never reach the UI. +func (d *Status) BumpNetworksRevision() { + d.networksRevision.Add(1) + d.notifyStateChange() +} + +// GetNetworksRevision returns the current routed-networks revision, surfaced in +// the status snapshot so the UI can detect route/selection changes (see +// BumpNetworksRevision). +func (d *Status) GetNetworksRevision() uint64 { + return d.networksRevision.Load() +} + func (d *Status) SetWgIface(wgInterface WGIfaceStatus) { d.mux.Lock() defer d.mux.Unlock() diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 839ec14c0..2325267fd 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -439,6 +439,11 @@ func (m *DefaultManager) UpdateRoutes( m.updateClientNetworks(updateSerial, filteredClientRoutes) m.notifier.OnNewRoutes(filteredClientRoutes) + // A new network map can add or drop route/exit-node candidates without + // touching any peer's chosen-route state, so the peer status alone + // wouldn't notify SubscribeStatus subscribers. Bump the revision so the + // UI re-fetches ListNetworks. + m.statusRecorder.BumpNetworksRevision() } m.clientRoutes = clientRoutes @@ -579,6 +584,10 @@ func (m *DefaultManager) TriggerSelection(networks route.HAMap) { if err := m.stateManager.UpdateState((*SelectorState)(m.routeSelector)); err != nil { log.Errorf("failed to update state: %v", err) } + + // A selection change flips Network.selected without altering the candidate + // set, so bump the revision to push the new state to the UI. + m.statusRecorder.BumpNetworksRevision() } // stopObsoleteClients stops the client network watcher for the networks that are not in the new list diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 2e3f453d4..aff296344 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -2122,8 +2122,13 @@ type FullStatus struct { Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"` LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"` SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // networksRevision bumps whenever the set of routed networks (route and + // exit-node candidates) or their selected state changes. The UI fingerprints + // on it to know when to re-fetch ListNetworks via the push stream, instead + // of polling on every status snapshot. + NetworksRevision uint64 `protobuf:"varint,11,opt,name=networksRevision,proto3" json:"networksRevision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *FullStatus) Reset() { @@ -2226,6 +2231,13 @@ func (x *FullStatus) GetSshServerState() *SSHServerState { return nil } +func (x *FullStatus) GetNetworksRevision() uint64 { + if x != nil { + return x.NetworksRevision + } + return 0 +} + // Networks type ListNetworksRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -6763,7 +6775,7 @@ const file_daemon_proto_rawDesc = "" + "\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" + "\x0eSSHServerState\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + - "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xaf\x04\n" + + "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xdb\x04\n" + "\n" + "FullStatus\x12A\n" + "\x0fmanagementState\x18\x01 \x01(\v2\x17.daemon.ManagementStateR\x0fmanagementState\x125\n" + @@ -6777,7 +6789,8 @@ const file_daemon_proto_rawDesc = "" + "\x06events\x18\a \x03(\v2\x13.daemon.SystemEventR\x06events\x124\n" + "\x15lazyConnectionEnabled\x18\t \x01(\bR\x15lazyConnectionEnabled\x12>\n" + "\x0esshServerState\x18\n" + - " \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\"\x15\n" + + " \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\x12*\n" + + "\x10networksRevision\x18\v \x01(\x04R\x10networksRevision\"\x15\n" + "\x13ListNetworksRequest\"?\n" + "\x14ListNetworksResponse\x12'\n" + "\x06routes\x18\x01 \x03(\v2\x0f.daemon.NetworkR\x06routes\"a\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 4554a3081..6c47c71aa 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -443,6 +443,12 @@ message FullStatus { bool lazyConnectionEnabled = 9; SSHServerState sshServerState = 10; + + // networksRevision bumps whenever the set of routed networks (route and + // exit-node candidates) or their selected state changes. The UI fingerprints + // on it to know when to re-fetch ListNetworks via the push stream, instead + // of polling on every status snapshot. + uint64 networksRevision = 11; } // Networks diff --git a/client/server/server.go b/client/server/server.go index e7fcfb78d..d84941023 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -1242,6 +1242,7 @@ func (s *Server) buildStatusResponse(msg *proto.StatusRequest) (*proto.StatusRes pbFullStatus := fullStatus.ToProto() pbFullStatus.Events = s.statusRecorder.GetEventHistory() pbFullStatus.SshServerState = s.getSSHServerState() + pbFullStatus.NetworksRevision = s.statusRecorder.GetNetworksRevision() statusResponse.FullStatus = pbFullStatus } diff --git a/client/ui/frontend/src/modules/networks/NetworksContext.tsx b/client/ui/frontend/src/modules/networks/NetworksContext.tsx index 14768c1fb..08308e334 100644 --- a/client/ui/frontend/src/modules/networks/NetworksContext.tsx +++ b/client/ui/frontend/src/modules/networks/NetworksContext.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { Networks as NetworksSvc } from "@bindings/services"; import type { Network } from "@bindings/services/models.js"; +import { useStatus } from "@/modules/daemon-status/StatusContext"; // A range is treated as an exit-node candidate when any of its CIDRs is a // default route (v4 or v6). The daemon may merge a v4+v6 pair into a single @@ -40,6 +41,7 @@ export const useNetworks = () => { }; export const NetworksProvider = ({ children }: { children: ReactNode }) => { + const { status } = useStatus(); const [routes, setRoutes] = useState([]); const refresh = useCallback(async () => { @@ -51,9 +53,15 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => { } }, []); + // The daemon bumps networksRevision whenever the routed-network set or a + // selection changes (from any surface) and pushes it on the status stream. + // Refetch on every bump so the list stays live without polling — and on + // mount, since the revision is already defined by the time this provider + // renders (StatusProvider only mounts children once the daemon is reachable). + const networksRevision = status?.networksRevision; useEffect(() => { void refresh(); - }, [refresh]); + }, [refresh, networksRevision]); const toggleNetwork = useCallback( async (id: string, selected: boolean) => { @@ -120,7 +128,5 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => { }; }, [routes, refresh, toggleNetwork, toggleExitNode]); - return ( - {children} - ); + return {children}; }; diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index dec785537..d239b3fb2 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -38,6 +38,7 @@ "notify.error.connect": "Verbindung fehlgeschlagen", "notify.error.disconnect": "Trennen fehlgeschlagen", "notify.error.switchProfile": "Wechsel zu {profile} fehlgeschlagen", + "notify.error.exitNode": "Exit-Node {name} konnte nicht aktualisiert werden", "notify.sessionExpired.title": "NetBird-Sitzung abgelaufen", "notify.sessionExpired.body": "Ihre NetBird-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", "notify.sessionWarning.title": "Sitzung läuft bald ab", diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index a00e94d2c..f3b9beb3a 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -38,6 +38,7 @@ "notify.error.connect": "Failed to connect", "notify.error.disconnect": "Failed to disconnect", "notify.error.switchProfile": "Failed to switch to {profile}", + "notify.error.exitNode": "Failed to update exit node {name}", "notify.sessionExpired.title": "NetBird session expired", "notify.sessionExpired.body": "Your NetBird session has expired. Please log in again.", "notify.sessionWarning.title": "Session expires soon", diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 01a368899..cd5b72c83 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -38,6 +38,7 @@ "notify.error.connect": "Csatlakozás sikertelen", "notify.error.disconnect": "Bontás sikertelen", "notify.error.switchProfile": "Átváltás sikertelen erre: {profile}", + "notify.error.exitNode": "A kilépő csomópont frissítése sikertelen: {name}", "notify.sessionExpired.title": "NetBird munkamenet lejárt", "notify.sessionExpired.body": "A NetBird munkamenet lejárt. Kérjük, jelentkezzen be újra.", "notify.sessionWarning.title": "Munkamenet hamarosan lejár", diff --git a/client/ui/main.go b/client/ui/main.go index 80e5f206c..6eb39e83d 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -167,7 +167,8 @@ func main() { authSession := authsession.NewSession(conn) app.RegisterService(application.NewService(services.NewSession(authSession))) app.RegisterService(application.NewService(settings)) - app.RegisterService(application.NewService(services.NewNetworks(conn))) + networks := services.NewNetworks(conn) + app.RegisterService(application.NewService(networks)) app.RegisterService(application.NewService(services.NewForwarding(conn))) app.RegisterService(application.NewService(profiles)) app.RegisterService(application.NewService(services.NewDebug(conn))) @@ -233,6 +234,7 @@ func main() { Connection: connection, Settings: settings, Profiles: profiles, + Networks: networks, Peers: peers, Notifier: notifier, Update: update, diff --git a/client/ui/services/peers.go b/client/ui/services/peers.go index 651362c4f..3f812af3a 100644 --- a/client/ui/services/peers.go +++ b/client/ui/services/peers.go @@ -126,6 +126,10 @@ type Status struct { Local LocalPeer `json:"local"` Peers []PeerStatus `json:"peers"` Events []SystemEvent `json:"events"` + // NetworksRevision bumps whenever the daemon's routed-networks set or their + // selected state changes. Consumers fingerprint on it to know when to + // re-fetch ListNetworks instead of polling every snapshot. + NetworksRevision uint64 `json:"networksRevision"` // SessionExpiresAt is the absolute UTC instant at which the peer's // SSO session expires. nil when the peer is not SSO-tracked or login // expiration is disabled (either server-side off, or peer not @@ -425,8 +429,9 @@ func statusFromProto(resp *proto.StatusResponse) Status { local := full.GetLocalPeerState() st := Status{ - Status: resp.GetStatus(), - DaemonVersion: resp.GetDaemonVersion(), + Status: resp.GetStatus(), + DaemonVersion: resp.GetDaemonVersion(), + NetworksRevision: full.GetNetworksRevision(), Management: PeerLink{ URL: mgmt.GetURL(), Connected: mgmt.GetConnected(), diff --git a/client/ui/tray.go b/client/ui/tray.go index 00223df1e..56ba6b2d0 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -73,6 +73,7 @@ type TrayServices struct { Connection *services.Connection Settings *services.Settings Profiles *services.Profiles + Networks *services.Networks Peers *services.Peers Notifier *notifications.NotificationService Update *services.Update @@ -122,9 +123,18 @@ type Tray struct { updater *trayUpdater - mu sync.Mutex - connected bool - exitNodes []string + mu sync.Mutex + connected bool + // exitNodes are the rows currently painted into the Exit Node submenu, + // sourced from Networks.List() (NetID + selected state) so each row can be + // toggled. lastNetworksRevision is the daemon's routed-networks revision + // from the last Status snapshot; a bump in it — or a connect/disconnect + // transition — is what triggers a refreshExitNodes re-fetch, so we hit + // ListNetworks only when routes or their selection actually change rather + // than on every push. The peer-status route list can't be used here: it + // only carries actively-routed (chosen) routes, not candidate exit nodes. + exitNodes []exitNodeEntry + lastNetworksRevision uint64 lastStatus string lastDaemonVersion string notificationsEnabled bool @@ -148,6 +158,11 @@ type Tray struct { // SetMenu, which the Wails menu API is not safe against concurrent // callers. profileLoadMu sync.Mutex + // exitNodesLoadMu serializes refreshExitNodes for the same reason: the + // Status stream can fire several pushes in quick succession and each may + // kick a refresh, but the ListNetworks fetch + submenu rebuild + SetMenu + // must not run concurrently with itself. + exitNodesLoadMu sync.Mutex } func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray { @@ -267,7 +282,7 @@ func (t *Tray) reapplyMenuState() { connected := t.connected lastStatus := t.lastStatus daemonVersion := t.lastDaemonVersion - exitNodes := append([]string(nil), t.exitNodes...) + exitNodes := append([]exitNodeEntry(nil), t.exitNodes...) sessionDeadline := t.sessionExpiresAt t.mu.Unlock() @@ -311,7 +326,13 @@ func (t *Tray) reapplyMenuState() { if t.updater != nil { t.updater.applyLanguage() } + // buildMenu just recreated an empty Exit Node submenu, so repaint the + // cached rows unconditionally (a refreshExitNodes would skip the rebuild + // when the list is unchanged). Hold exitNodesLoadMu so this rebuild can't + // race a status-push-driven refreshExitNodes mutating the same submenu. + t.exitNodesLoadMu.Lock() t.rebuildExitNodes(exitNodes) + t.exitNodesLoadMu.Unlock() go t.loadProfiles() } @@ -645,9 +666,8 @@ func (t *Tray) applyStatus(st services.Status) { t.lastDaemonVersion = st.DaemonVersion } - exitNodes := exitNodesFromStatus(st) - exitNodesChanged := !equalStrings(exitNodes, t.exitNodes) - t.exitNodes = exitNodes + revisionChanged := st.NetworksRevision != t.lastNetworksRevision + t.lastNetworksRevision = st.NetworksRevision t.mu.Unlock() if triggerLogin { @@ -687,15 +707,10 @@ func (t *Tray) applyStatus(st services.Status) { t.downItem.SetHidden(!connected && !connecting) t.downItem.SetEnabled(connected || connecting) } - // Exit Node surfaces tunnel-routed state, so only expose it while - // the tunnel is up AND the account actually has at least one - // exit-node candidate (a peer advertising 0.0.0.0/0 or ::/0). - // The row stays visible but greyed when no candidate is around, - // so the user can tell the feature exists. Settings just needs - // the daemon socket reachable. - if t.exitNodeItem != nil { - t.exitNodeItem.SetEnabled(connected && len(exitNodes) > 0) - } + // Exit Node parent-item enablement (greyed unless the tunnel is up + // AND at least one candidate exists) is owned by refreshExitNodes, + // triggered below on this same transition. Settings just needs the + // daemon socket reachable. if t.settingsItem != nil { t.settingsItem.SetEnabled(!daemonUnavailable) } @@ -713,20 +728,14 @@ func (t *Tray) applyStatus(st services.Status) { // reads item.disabled/item.hidden at NSMenuItem construction time. go t.loadProfiles() } - if exitNodesChanged { - // Re-evaluate the parent item's enablement here too, not only in - // the iconChanged block above: the daemon ships peer routes in a - // later snapshot than the Connected status text (networks=[] first, - // then populated), so the candidate list can flip empty→non-empty - // while the status string is unchanged. Without this the parent - // "Exit Node" item stays greyed from when it was last set on the - // status transition and the freshly painted rows are unreachable. - // Set before rebuildExitNodes' SetMenu so the rebuild reads the - // updated enabled state at NSMenuItem construction time. - if t.exitNodeItem != nil { - t.exitNodeItem.SetEnabled(connected && len(exitNodes) > 0) - } - t.rebuildExitNodes(exitNodes) + // Re-fetch the selectable exit-node list whenever the daemon's routed- + // networks revision bumps (a route candidate added/removed, or a selection + // applied from any surface) or the tunnel flips state (iconChanged). The + // revision is the only reliable signal: candidate routes never appear in + // the peer-status snapshot, so a removed exit node would otherwise go + // unnoticed. The refresh owns the parent item's enablement and the rebuild. + if iconChanged || revisionChanged { + go t.refreshExitNodes() } if daemonVersionChanged && t.daemonVersionItem != nil { t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", st.DaemonVersion)) @@ -754,27 +763,114 @@ func (t *Tray) handleSessionExpired() { } } -// rebuildExitNodes paints one row per exit-node candidate into the -// Exit Node submenu. The list is read-only for now — selection wiring -// would need ListNetworks + a peer-FQDN → network-ID lookup, which the -// PeerStatus stream doesn't ship. Rebuilds via Clear + Add so the row -// set stays in sync with the daemon snapshot; SetMenu on the root menu -// is required because Wails v3 alpha menu Update() builds a detached -// NSMenu on darwin that never replaces the empty submenu attached at -// initial setup (same workaround as loadProfiles). -func (t *Tray) rebuildExitNodes(nodes []string) { +// rebuildExitNodes paints one clickable row per exit-node candidate into the +// Exit Node submenu. Each row carries the network's NetID and its selected +// state from ListNetworks; clicking toggles it via toggleExitNode. The active +// node is marked with a "✓ " prefix using a plain Add rather than AddCheckbox +// for the same reason as loadProfiles — Wails auto-toggles a checkbox's state +// on click before the OnClick handler runs, so the deselect/select round-trip +// would briefly show two checked rows. Rebuilds via Clear + Add so the row set +// stays in sync; SetMenu on the root menu is required because Wails v3 alpha +// menu Update() builds a detached NSMenu on darwin that never replaces the +// empty submenu attached at initial setup (same workaround as loadProfiles). +// Callers must hold exitNodesLoadMu so concurrent rebuilds can't race the +// submenu's item slice. +func (t *Tray) rebuildExitNodes(nodes []exitNodeEntry) { if t.exitNodeSubmenu == nil { return } t.exitNodeSubmenu.Clear() - for _, fqdn := range nodes { - t.exitNodeSubmenu.Add(fqdn).SetEnabled(false) + for _, n := range nodes { + id := n.ID + selected := n.Selected + label := id + if selected { + label = "✓ " + id + } + t.exitNodeSubmenu.Add(label).OnClick(func(*application.Context) { + t.toggleExitNode(id, selected) + }) } if t.menu != nil { t.tray.SetMenu(t.menu) } } +// refreshExitNodes re-fetches the routed-network list from the daemon and +// repaints the Exit Node submenu. Sourcing the rows from Networks.List() (not +// the Status stream) is what makes them selectable: the stream only ships peer +// FQDNs, whereas ListNetworks returns the NetID + selected state the +// Select/Deselect RPCs need. Serialized by exitNodesLoadMu so overlapping +// Status pushes can't race the submenu rebuild. Owns the parent item's +// enablement: greyed unless the tunnel is up and at least one candidate exists. +func (t *Tray) refreshExitNodes() { + t.exitNodesLoadMu.Lock() + defer t.exitNodesLoadMu.Unlock() + + t.mu.Lock() + connected := t.connected + t.mu.Unlock() + + var nodes []exitNodeEntry + if connected { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + list, err := t.svc.Networks.List(ctx) + cancel() + if err != nil { + log.Debugf("tray list networks: %v", err) + return + } + nodes = exitNodesFromNetworks(list) + } + + log.Infof("tray refreshExitNodes: %d exit node(s)", len(nodes)) + for _, n := range nodes { + log.Infof("tray exit node: id=%q selected=%v", n.ID, n.Selected) + } + + t.mu.Lock() + changed := !equalExitNodes(nodes, t.exitNodes) + t.exitNodes = nodes + t.mu.Unlock() + + // Set enablement before rebuildExitNodes' SetMenu so the rebuild reads the + // updated state at NSMenuItem construction time (Wails v3 alpha reads + // item.disabled at build time, not lazily). + if t.exitNodeItem != nil { + t.exitNodeItem.SetEnabled(connected && len(nodes) > 0) + } + if changed { + t.rebuildExitNodes(nodes) + } +} + +// toggleExitNode activates or deactivates one exit node by NetID. Exit nodes +// are mutually exclusive, so Select uses append=false to clear any other +// active node before turning this one on; deselecting an active node turns +// routing off entirely. Mirrors the frontend's toggleExitNode semantics. Runs +// the RPC off the menu-click goroutine and re-fetches so the ✓ moves to the +// new selection. +func (t *Tray) toggleExitNode(id string, selected bool) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + params := services.SelectNetworksParams{NetworkIDs: []string{id}, Append: false, All: false} + var err error + if selected { + err = t.svc.Networks.Deselect(ctx, params) + } else { + err = t.svc.Networks.Select(ctx, params) + } + if err != nil { + log.Errorf("tray toggle exit node %q: %v", id, err) + t.notifyError(t.loc.T("notify.error.exitNode", "name", id)) + return + } + t.refreshExitNodes() + }() +} + // applyStatusIndicator sets the small coloured dot shown on the status // menu entry. The dot mirrors the tray icon's state through a fixed // palette: green for Connected, yellow for Connecting, blue for the @@ -955,13 +1051,11 @@ func (t *Tray) loadProfiles() { } sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name }) - log.Infof("tray loadProfiles: received %d profile(s) for user %q", len(profiles), username) t.profileSubmenu.Clear() var activeName, activeEmail string for _, p := range profiles { name := p.Name active := p.IsActive - log.Infof("tray loadProfiles: profile=%q active=%v", name, active) // Use Add instead of AddCheckbox: Wails auto-toggles a checkbox's // checked state on click (before the OnClick handler fires), so with // AddCheckbox both the old and the new profile would briefly show as @@ -984,6 +1078,7 @@ func (t *Tray) loadProfiles() { activeEmail = p.Email } } + log.Infof("tray loadProfiles: received %d profile(s) for user %q, active=%q", len(profiles), username, activeName) if t.profileSubmenuItem != nil && activeName != "" { t.profileSubmenuItem.SetLabel(activeName) } @@ -1357,44 +1452,38 @@ func (t *Tray) notifyError(message string) { t.notify(t.loc.T("notify.error.title"), message, notifyIDTrayError) } -// exitNodesFromStatus returns the FQDNs of peers advertising an IPv4 -// or IPv6 default route (`0.0.0.0/0` or `::/0`) — the only candidates -// the user can pick as an exit node. The daemon ships each peer's -// route table as `maps.Keys(...)` of a CIDR-keyed map (see -// client/internal/peer/status.go: pbPeerState.Networks = maps.Keys( -// peerState.GetRoutes())), so we parse each entry with netip and -// match by `Bits()==0 && Addr().IsUnspecified()` rather than -// string-comparing "0.0.0.0/0" — that catches the v4/v6 partner -// management pairs together for a dual-stack exit, and tolerates any -// future canonicalisation of the prefix string. -func exitNodesFromStatus(st services.Status) []string { - seen := map[string]struct{}{} - out := []string{} - for _, p := range st.Peers { - if p.Fqdn == "" { +// exitNodeEntry is one selectable row in the Exit Node submenu. ID is the +// network's NetID — both the row label and the argument the Select/Deselect +// RPCs take; Selected drives the ✓ prefix. +type exitNodeEntry struct { + ID string + Selected bool +} + +// exitNodesFromNetworks filters the daemon's routed-network list down to +// exit-node candidates (a default-route range) and maps them to selectable +// rows. Sorted case-insensitively by ID so the submenu reads alphabetically. +func exitNodesFromNetworks(networks []services.Network) []exitNodeEntry { + out := []exitNodeEntry{} + for _, n := range networks { + if !rangeIsDefaultRoute(n.Range) { continue } - if !advertisesDefaultRoute(p.Networks) { - continue - } - if _, ok := seen[p.Fqdn]; ok { - continue - } - seen[p.Fqdn] = struct{}{} - out = append(out, p.Fqdn) + out = append(out, exitNodeEntry{ID: n.ID, Selected: n.Selected}) } - // Case-insensitive sort so the submenu reads alphabetically the - // way a human would — sort.Strings alone would put every - // uppercase letter ahead of any lowercase one. sort.Slice(out, func(i, j int) bool { - return strings.ToLower(out[i]) < strings.ToLower(out[j]) + return strings.ToLower(out[i].ID) < strings.ToLower(out[j].ID) }) return out } -func advertisesDefaultRoute(networks []string) bool { - for _, n := range networks { - pref, err := netip.ParsePrefix(n) +// rangeIsDefaultRoute reports whether a Network.Range string contains an IPv4 +// or IPv6 default route. The daemon may merge a v4+v6 exit pair into a single +// comma-joined range ("0.0.0.0/0, ::/0"), so we split and check each part, +// matching by Bits()==0 && unspecified rather than a literal string compare. +func rangeIsDefaultRoute(r string) bool { + for _, part := range strings.Split(r, ",") { + pref, err := netip.ParsePrefix(strings.TrimSpace(part)) if err != nil { continue } @@ -1405,7 +1494,7 @@ func advertisesDefaultRoute(networks []string) bool { return false } -func equalStrings(a, b []string) bool { +func equalExitNodes(a, b []exitNodeEntry) bool { if len(a) != len(b) { return false }