diff --git a/.agents/skills/drive-trayscale/SKILL.md b/.agents/skills/drive-trayscale/SKILL.md index 667fcb5f..d85a73ad 100644 --- a/.agents/skills/drive-trayscale/SKILL.md +++ b/.agents/skills/drive-trayscale/SKILL.md @@ -110,6 +110,7 @@ AT-SPI handles: | `No matching peers` | `grouping` | Sidebar placeholder when a search matches nothing | | `This machine`, `Exit Nodes`, `Online`, `Offline` | `label` | Sidebar section titles when connected | | `Tailscale IPs`, `Options`, `Files`, `Advertised Routes`, `Network Check` | `grouping` | This-machine page | +| `Exit node` | `list item` | First row in the self page `Options`. Its second `label` is the subtitle: `None`, the peer name, the Mullvad location (for example `πŸ‡ΈπŸ‡ͺ Stockholm, Sweden`), an IP, `Waiting for an exit node` while automatic selection has no node yet, or `Unavailable` when a manually chosen node is no longer in the netmap. When the node has a page the row also has an `image ''` child (the arrow). It exposes no AT-SPI action in either state, so `click` cannot activate it; use a pointer click | | `Advertise exit node` and the other option rows | `switch` | Read only | | `Tailscale IPs`, `Misc.`, `Advertised Routes` | `grouping` | Peer page | | `Use as exit node` | `switch` | Peer page of an exit node. Read only | @@ -117,6 +118,7 @@ AT-SPI handles: | `Trayscale`, `DeedleFake` | `label` | Inside the About dialog | | `Preferences` | `dialog` | After `action preferences` | | `General`, `Taildrop` | `grouping` | Inside Preferences | +| `Exit Node` | `combo box` | Inside `General`. The current choice is a `label` (`Previous exit node` or `Recommended exit node`) inside its `list item ''`. It exposes no AT-SPI action, so `click` cannot open it | | (empty) | `toggle button` | Page menu button in the content header: `click --role "toggle button" --name "" --exact --index 1` (index 0 is the main menu). Insensitive when no peer page is visible | | (empty) | `menu item` | Page menu items, in order: index 0 `Copy FQDN`, 1 `Send file...`, 2 `Send directory...`. Need `snapshot --max-depth 60` to see | | `Select dir(s) to send to ` / `Select file(s) ...` | `dialog` | File chooser from the send items. `press --key /` opens its location field (`text ''`); `fill --role text --name "" --exact --value `; then `click --role button --name Open` (file) or `Select` (directory) | @@ -147,7 +149,7 @@ Without a GNOME session (for example Xvfb plus a private `dbus-daemon --session` .agents/skills/drive-trayscale/scripts/tsharness/run.sh # prints READY, runs until interrupted ``` -Flags: `-extra N` adds `peer-NN` peers (every third offline); `-mullvad N` adds Mullvad exit nodes in a few cities and grants this machine the `mullvad` capability, so the `Mullvad Exit Nodes` page appears; `-churn 20s` flips one peer's online state (and toggles an extra route on routers) per tick; `-receiver` also starts a second real `tailscaled`, hostname `receiver`, and grants both nodes Taildrop, so sends from Trayscale to `receiver` complete and land in `/tmp/tsharness/receiver/state/files/*/` (a directory arrives as `.tar.zst`). Clear that directory before a run that checks it. Use `/tmp/tsharness/tailscale` for CLI changes against the fake node. +Flags: `-extra N` adds `peer-NN` peers (every third offline); `-mullvad N` adds Mullvad exit nodes in a few cities and grants this machine the `mullvad` capability, so the `Mullvad Exit Nodes` page appears; `-suggest-exit-nodes` makes exit nodes eligible for recommendation, so the `Recommended exit node` preference can resolve a node; `-remove-exit-after 60s` removes `bravo-exit` from this machine's netmap that long after `READY`, as control does when a node is deleted, and testcontrol sends this machine no further netmaps; `-churn 20s` flips one peer's online state (and toggles an extra route on routers) per tick; `-receiver` also starts a second real `tailscaled`, hostname `receiver`, and grants both nodes Taildrop, so sends from Trayscale to `receiver` complete and land in `/tmp/tsharness/receiver/state/files/*/` (a directory arrives as `.tar.zst`). Clear that directory before a run that checks it. Use `/tmp/tsharness/tailscale` for CLI changes against the fake node. It serves the LocalAPI on `/run/tailscale/tailscaled.sock`, Trayscale's default socket, so use it only where no system `tailscaled` runs (create `/run/tailscale` writable by you first). Its `go.mod` pins `tailscale.com` separately from the app's; `run.sh` builds `tailscale`/`tailscaled` at the app's version into `$DIR` (default `/tmp/tsharness`). Go skips dot-directories, so CI does not build it. diff --git a/.agents/skills/drive-trayscale/scripts/tsharness/main.go b/.agents/skills/drive-trayscale/scripts/tsharness/main.go index bd269e6c..920436b0 100644 --- a/.agents/skills/drive-trayscale/scripts/tsharness/main.go +++ b/.agents/skills/drive-trayscale/scripts/tsharness/main.go @@ -42,15 +42,17 @@ type Peer struct { // Harness is the reusable fixture. type Harness struct { - Dir string // state, logs, binaries - Socket string // tailscaled LocalAPI socket - Tailscaled string // path to tailscaled binary - Tailscale string // path to tailscale CLI binary - Hostname string // this machine's hostname - Peers []Peer - Churn time.Duration // if nonzero, flip peer state this often - Mullvad bool // grant this machine the mullvad capability - Receiver bool // run a second real node, "receiver", that accepts Taildrop + Dir string // state, logs, binaries + Socket string // tailscaled LocalAPI socket + Tailscaled string // path to tailscaled binary + Tailscale string // path to tailscale CLI binary + Hostname string // this machine's hostname + Peers []Peer + Churn time.Duration // if nonzero, flip peer state this often + Mullvad bool // grant this machine the mullvad capability + Receiver bool // run a second real node, "receiver", that accepts Taildrop + SuggestExitNodes bool // allow fake exit nodes to be recommended automatically + RemoveExitAfter time.Duration // if nonzero, remove bravo-exit from this machine's netmap after this long } const capFileSharing = tailcfg.NodeCapability(tailcfg.CapabilityFileSharing) @@ -128,6 +130,7 @@ func (h *Harness) Run(ctx context.Context) error { ctrl.SetNodeCapMap(selfKey, selfCaps) } var peerKeys []key.NodePublic + var removeID tailcfg.NodeID for i, p := range h.Peers { before := map[tailcfg.NodeID]bool{} for _, n := range ctrl.AllNodes() { @@ -158,11 +161,20 @@ func (h *Harness) Run(ctx context.Context) error { n.Created = time.Now().Add(-30 * 24 * time.Hour) if p.ExitNode { n.AllowedIPs = append(n.AllowedIPs, netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")) + if h.SuggestExitNodes && p.Location == nil { + n.HomeDERP = 1 // The harness's local DERP region. + } } n.AllowedIPs = append(n.AllowedIPs, p.Routes...) n.PrimaryRoutes = append(n.PrimaryRoutes, p.Routes...) ctrl.UpdateNode(n) + if p.ExitNode && h.SuggestExitNodes { + ctrl.SetNodeCapMap(n.Key, tailcfg.NodeCapMap{tailcfg.NodeAttrSuggestExitNode: nil}) + } peerKeys = append(peerKeys, n.Key) + if p.Host == "bravo-exit" { + removeID = n.ID + } } } if h.Receiver { @@ -185,6 +197,14 @@ func (h *Harness) Run(ctx context.Context) error { if h.Churn > 0 { go churn(ctx, ctrl, peerKeys, h.Churn) } + if h.RemoveExitAfter > 0 { + time.AfterFunc(h.RemoveExitAfter, func() { + // Real control sends a delta like this when a node is deleted. + // testcontrol then stops sending this node full netmaps. + ok := ctrl.AddRawMapResponse(selfKey, &tailcfg.MapResponse{PeersRemoved: []tailcfg.NodeID{removeID}}) + log.Printf("removed bravo-exit from netmap: %v", ok) + }) + } <-ctx.Done() _ = cli("down") return nil @@ -274,6 +294,8 @@ func main() { churnEvery := flag.Duration("churn", 0, "flip one peer's state this often (0 disables)") receiver := flag.Bool("receiver", false, "run a second real node, receiver, that accepts Taildrop from this machine") mullvad := flag.Int("mullvad", 0, "Mullvad exit nodes to register (also grants the mullvad capability)") + suggestExitNodes := flag.Bool("suggest-exit-nodes", false, "allow fake exit nodes to be recommended automatically") + removeExitAfter := flag.Duration("remove-exit-after", 0, "remove bravo-exit from this machine's netmap this long after READY (0 disables)") flag.Parse() ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() @@ -288,9 +310,11 @@ func main() { {Host: "echo-down", Online: false, ExitNode: true}, {Host: "delta-router", Online: true, Routes: []netip.Prefix{netip.MustParsePrefix("192.168.50.0/24")}}, }, - Churn: *churnEvery, - Mullvad: *mullvad > 0, - Receiver: *receiver, + Churn: *churnEvery, + Mullvad: *mullvad > 0, + Receiver: *receiver, + SuggestExitNodes: *suggestExitNodes, + RemoveExitAfter: *removeExitAfter, } for i := range *extra { h.Peers = append(h.Peers, Peer{Host: fmt.Sprintf("peer-%02d", i), Online: i%3 != 0}) diff --git a/.agents/skills/verify-trayscale/features/README.md b/.agents/skills/verify-trayscale/features/README.md index 3f56aaae..8aa42bd8 100644 --- a/.agents/skills/verify-trayscale/features/README.md +++ b/.agents/skills/verify-trayscale/features/README.md @@ -43,6 +43,7 @@ Keep implementation details out of the map. Name only user paths, stable handles - [Main window](./main-window.md) covers the window title, sidebar sections, the Show Offline Peers toggle, and connected vs not-connected chrome. - [This machine](./this-machine.md) covers the self page: hostname, Tailscale IPs, options, files, routes, and network check. +- [Exit node](./exit-node.md) covers the Exit Node preference, turning exit node use on from the menu, and the self page row that opens the node in use. - [About](./about.md) covers the About dialog from the app menu action. - [Preferences](./preferences.md) covers the Preferences dialog and its General and Taildrop groups. - [Peer search](./peer-search.md) covers opening search from the toolbar button and from Ctrl+F, filtering, the empty result, and closing search. diff --git a/.agents/skills/verify-trayscale/features/exit-node.md b/.agents/skills/verify-trayscale/features/exit-node.md new file mode 100644 index 00000000..f8e54d3d --- /dev/null +++ b/.agents/skills/verify-trayscale/features/exit-node.md @@ -0,0 +1,49 @@ +# Exit node + +The `Exit Node` preference picks how Trayscale chooses an exit node when the user turns exit node use on from Trayscale's menu: `Previous exit node` (the default) or `Recommended exit node`. The self page shows the exit node in use, and its row opens that node's page. + +## Sub-features + +- `exit-node-preference` shows the `Exit Node` combo row in the Preferences `General` group with `Previous exit node` selected by default. +- `exit-node-previous` turns exit node use on from the menu and gets the last node back, with automatic selection off. +- `exit-node-recommended` turns exit node use on from the menu and lets Tailscale pick a node automatically. +- `exit-node-row` shows the self page `Exit node` row: `None`, the peer name, the Mullvad location, a bare IP, `Waiting for an exit node` while Recommended has not picked a node yet, or `Unavailable` when a manually chosen node has left the netmap. +- `exit-node-open-peer` opens the exit node's peer page from that row. +- `exit-node-open-mullvad` opens the Mullvad page with the node's country expanded and its row in view. + +## How to get to it (user POV) + +- Open Preferences from the main menu and use `Exit Node` in `General`. +- Turn exit node use on or off from the main menu or the tray menu. +- Open the self page under `This machine`. `Exit node` is the first row in `Options`. + +## Driving it with control-trayscale + +Preconditions: + +- `doctor` reports `frame 'Trayscale'` and `gsettings_backend` equal to `memory`. +- Before every step that changes Tailscale state, `check-fake-tailnet` must report `FAKE`. This recipe runs `use_exit_node` and `Use as exit node` only against tsharness. Never run these steps against the user's real tailnet. +- The test node is connected and does not advertise itself as an exit node. +- Run tsharness with `-suggest-exit-nodes` so Recommended can resolve a node. Add `-mullvad N` for the Mullvad checks. +- The `Exit Node` combo box and the `Exit node` row expose no AT-SPI action. Changing the preference and activating the row need a pointer click (for example `xdotool` on X11). Without one, run only the steps that use actions and the CLI. +- `tailscale debug prefs` omits `AutoExitNode` when it is empty. Treat a missing key as empty. + +- **Preference.** Run `action preferences`, then snapshot `prefs.tree.txt`. `grouping 'General'` contains `combo box 'Exit Node'`, and its `list item ''` holds `label 'Previous exit node'`. +- **Row, none in use.** On the self page, snapshot `none.tree.txt`. `list item 'Exit node'` has `label 'None'` and no `image ''` child. +- **Previous.** Use a peer page's `Use as exit node` switch to select `bravo-exit`, then run `action use_exit_node --i-mean-it` twice (off, then on). Check `tailscale debug prefs` against the harness socket: `ExitNodeID` names `bravo-exit` and `AutoExitNode` is empty. +- **Recommended.** Click `combo box 'Exit Node'` and choose `Recommended exit node` from the popover, turn exit node use off and on with `use_exit_node`, and check that `AutoExitNode` is `any`. The `Exit node` row names whatever node Tailscale picked. Without `-suggest-exit-nodes`, `ExitNodeID` stays `auto:any` and the row says `Waiting for an exit node`. +- **Previous after Recommended.** Switch the preference back to `Previous exit node`, then toggle `use_exit_node` off and on. `AutoExitNode` is empty and `ExitNodeID` is the node automatic mode had chosen. +- **Node leaves the netmap.** Run tsharness with `-remove-exit-after 60s` and select `bravo-exit` with `$TS set --exit-node=bravo-exit` before the timer fires. After the harness logs `removed bravo-exit from netmap`, `ExitNodeID` still names it and `use_exit_node` is still on. The row says `Unavailable` and has no `image ''` child. With `-suggest-exit-nodes` and `$TS set --exit-node=auto:any` instead, the row says `Waiting for an exit node`. Restart tsharness afterwards, because testcontrol stops updating the netmap. +- **Open peer.** With `bravo-exit` in use, the row has `label 'bravo-exit'` and an `image ''` child. Click the row and snapshot `peer.tree.txt`. The visible page is `bravo-exit`'s peer page, with `switch 'Use as exit node' states=checked`. +- **Open Mullvad.** Select a Mullvad node with `$TS set --exit-node=`, activate the `Exit node` row, wait at least 300 ms, and snapshot `mullvad.tree.txt`. The Mullvad page is visible (its sidebar label becomes the location, for example `πŸ‡ΈπŸ‡ͺ Stockholm, Sweden`), the search `entry ''` is empty, the country is `list item ' ' states=expanded`, and the node is `switch '' states=checked,focused`. +- **Proof.** Keep every snapshot above with the `tailscale debug prefs` output next to each state change. + +## Gotchas + +- The preference applies only when Trayscale turns exit node use on. `tailscale set` and other clients ignore it. +- If exit node use is turned off outside Trayscale while automatic mode is on, Tailscale remembers automatic mode instead of a node, and Previous can restore only what Tailscale remembered. Turn it off from Trayscale when testing Previous. +- To get back to the self page, click `This machine`'s sidebar row. `select` marks it selected but does not change the page. +- Recommended can pick a different node on each run. Assert `AutoExitNode` and that the row names a node, not a specific server. +- Sharee nodes and nodes known only by IP have no page, so their row is not activatable. +- The Mullvad page expands the country first and focuses the node row after the expand animation. A snapshot taken right away may show only the country expanded. +- A private D-Bus session isolates the app, not the Tailscale socket. Use a separate test machine or a mount namespace exposing only the harness socket at Trayscale's default path. diff --git a/.agents/skills/verify-trayscale/features/preferences.md b/.agents/skills/verify-trayscale/features/preferences.md index c90b4cee..cb166efa 100644 --- a/.agents/skills/verify-trayscale/features/preferences.md +++ b/.agents/skills/verify-trayscale/features/preferences.md @@ -1,11 +1,11 @@ # Preferences -Preferences is a dialog with a General group (tray icon and polling interval) and a Taildrop group (auto-save received files). +Preferences is a dialog with a General group (tray icon, polling interval, and exit node selection) and a Taildrop group (auto-save received files). ## Sub-features - `prefs-open` opens the Preferences dialog from the app menu action. -- `prefs-general` shows a `General` group. +- `prefs-general` shows a `General` group, including the `Exit Node` combo row. See [Exit node](./exit-node.md) for what it does. - `prefs-taildrop` shows a `Taildrop` group. ## How to get to it (user POV) diff --git a/.agents/skills/verify-trayscale/features/this-machine.md b/.agents/skills/verify-trayscale/features/this-machine.md index a72d1120..54b6892d 100644 --- a/.agents/skills/verify-trayscale/features/this-machine.md +++ b/.agents/skills/verify-trayscale/features/this-machine.md @@ -7,6 +7,7 @@ This machine is the self peer page. It shows the hostname, Tailscale IPs with co - `self-identity` shows the hostname and MagicDNS name on the page. - `self-ips` lists each Tailscale address under `Tailscale IPs`. - `self-options` shows `Advertise exit node`, `Allow LAN access`, `Accept routes`, and `Accept DNS`. +- `self-exit-node` shows the `Exit node` row first in `Options`, with `None` or the node in use. See [Exit node](./exit-node.md) for activating it. - `self-files` shows `No incoming files.` when none are waiting. - `self-routes` shows `No advertised routes.` when none are advertised. - `self-netcheck` shows `Last run` with `Never` until a check runs. @@ -27,6 +28,7 @@ Preconditions: - **Identity.** Run `control-trayscale snapshot --path .agents/skills/verify-trayscale/artifacts/this-machine/page.tree.txt`. The `panel` named after this machine's sidebar label has a hostname label and a MagicDNS label. On Tailscale's coordination server the MagicDNS label ends in `.ts.net.`; other control servers can use another name. - **IPs.** That snapshot contains `grouping 'Tailscale IPs'` and at least one `list item` that is not `No addresses.`. Each address has a `button` with the same name as the address. - **Options.** The snapshot contains `switch 'Advertise exit node'`, `switch 'Allow LAN access'`, `switch 'Accept routes'`, and `switch 'Accept DNS'`. Do not toggle them. +- **Exit node.** The snapshot contains `list item 'Exit node'` whose second label is `None` or the node in use. Do not activate it here; [Exit node](./exit-node.md) covers that. - **Empty files and routes.** The snapshot contains `No incoming files.` and `No advertised routes.` unless the live node actually has files or routes. - **Netcheck.** The snapshot contains `grouping 'Network Check'`, `label 'Last run'`, and `label 'Never'` if no check has run in this session. - **Proof.** Keep `page.tree.txt`. It must include `frame 'Trayscale'`, `grouping 'Tailscale IPs'`, and the four option switches. diff --git a/README.md b/README.md index 076980d1..b256a490 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Tailscale Config Trayscale interfaces with the Tailscale daemon, `tailscaled`, to perform many of its operations. In order for this to work, the daemon must have been configured with the current user as the "operator". To do this, run `sudo tailscale set --operator=$USER` from the command-line at least once manually. +In Preferences, **Exit node selection** chooses what happens when **Use Exit Node** turns on: the previous exit node, or Tailscale's [recommended exit node](https://tailscale.com/docs/features/exit-nodes/auto-exit-nodes). The self page shows which exit node is in use and can open that node's page. + Installation ------------ diff --git a/dev.deedles.Trayscale.gschema.xml b/dev.deedles.Trayscale.gschema.xml index 2813b4ca..03c651f0 100644 --- a/dev.deedles.Trayscale.gschema.xml +++ b/dev.deedles.Trayscale.gschema.xml @@ -44,6 +44,19 @@ destination is configured. + + + + + + 'previous' + Exit node to use when turning on exit node use + + Either "previous" to reuse the most recently used exit node or + "recommended" to let Tailscale pick the recommended exit node + and keep tracking it. + + diff --git a/internal/gutil/data.go b/internal/gutil/data.go index 6f08b1ed..f6dd8ac6 100644 --- a/internal/gutil/data.go +++ b/internal/gutil/data.go @@ -1,6 +1,8 @@ package gutil import ( + "time" + "sync" "codeberg.org/puregotk/puregotk/v4/gio" @@ -61,6 +63,12 @@ func IdleAdd(f func()) { glib.IdleAdd(&idle, Hold(f)) } +// TimeoutAdd runs f once on the main loop after the given delay. It is +// safe to call from any goroutine. +func TimeoutAdd(delay time.Duration, f func()) { + glib.TimeoutAdd(uint32(delay.Milliseconds()), &idle, Hold(f)) +} + // AsyncCallback is the GAsyncReadyCallback for functions passed to // Async. Pass it along with Async's ID as the user data. var AsyncCallback gio.AsyncReadyCallback = func(_, res, id uintptr) { diff --git a/internal/tsutil/client.go b/internal/tsutil/client.go index ecfce186..90e356d0 100644 --- a/internal/tsutil/client.go +++ b/internal/tsutil/client.go @@ -59,9 +59,10 @@ func ExitNode(ctx context.Context, peer tailcfg.StableNodeID) error { var prefs ipn.Prefs prefs.ClearExitNode() _, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ - Prefs: prefs, - ExitNodeIDSet: true, - ExitNodeIPSet: true, + Prefs: prefs, + ExitNodeIDSet: true, + ExitNodeIPSet: true, + AutoExitNodeSet: true, }) if err != nil { return fmt.Errorf("edit prefs: %w", err) @@ -73,9 +74,10 @@ func ExitNode(ctx context.Context, peer tailcfg.StableNodeID) error { ExitNodeID: peer, } _, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ - Prefs: prefs, - ExitNodeIDSet: true, - ExitNodeIPSet: true, + Prefs: prefs, + ExitNodeIDSet: true, + ExitNodeIPSet: true, + AutoExitNodeSet: true, }) if err != nil { return fmt.Errorf("edit prefs: %w", err) @@ -84,22 +86,151 @@ func ExitNode(ctx context.Context, peer tailcfg.StableNodeID) error { return nil } -func SetUseExitNode(ctx context.Context, use bool) error { - useErr := localClient.SetUseExitNode(ctx, use) - if useErr == nil || !use { - return useErr +// ExitNodeSelection is how an exit node is chosen when exit node use +// is turned on. +type ExitNodeSelection int + +const ( + // SelectPreviousExitNode reuses the most recently used exit node. + SelectPreviousExitNode ExitNodeSelection = iota + + // SelectRecommendedExitNode lets the daemon pick and keep + // tracking the recommended exit node. + SelectRecommendedExitNode +) + +// ParseExitNodeSelection parses a setting value as written by +// [ExitNodeSelection.String]. Unknown values select the previous exit +// node. +func ParseExitNodeSelection(str string) ExitNodeSelection { + if str == "recommended" { + return SelectRecommendedExitNode + } + return SelectPreviousExitNode +} + +func (sel ExitNodeSelection) String() string { + if sel == SelectRecommendedExitNode { + return "recommended" + } + return "previous" +} + +// SetUseExitNode turns exit node use on or off. When turning it on, +// sel determines which exit node is used. +func SetUseExitNode(ctx context.Context, use bool, sel ExitNodeSelection) error { + if use && sel == SelectRecommendedExitNode { + err := useAutoExitNode(ctx) + if err == nil { + return nil + } + slog.Error("use automatic exit node", "err", err) + return errors.Join(err, useSuggestedExitNode(ctx)) } - suggested, suggestErr := localClient.SuggestExitNode(ctx) - if suggestErr == nil { - slog.Info("got suggested exit node", "id", suggested.ID, "name", suggested.Name, "location", suggested.Location) - suggestErr = ExitNode(ctx, suggested.ID) + if !use { + if err := pinAutoExitNode(ctx); err != nil { + slog.Error("pin automatic exit node before disabling", "err", err) + } + return localClient.SetUseExitNode(ctx, false) + } + + useErr := localClient.SetUseExitNode(ctx, true) + if useErr != nil { + suggestErr := useSuggestedExitNode(ctx) if suggestErr == nil { return nil } + return errors.Join(useErr, suggestErr) + } + + return keepPreviousExitNode(ctx) +} + +// pinAutoExitNode replaces automatic exit node selection with the node +// that it currently resolves to. The daemon remembers automatic mode, +// rather than the node, as the prior exit node when exit node use is +// turned off, so without this, turning it back on with +// [SelectPreviousExitNode] can't get the node that was in use back. +func pinAutoExitNode(ctx context.Context) error { + prefs, err := Prefs(ctx) + if err != nil { + return fmt.Errorf("get prefs: %w", err) + } + id := prefs.ExitNodeID + if !prefs.AutoExitNode.IsSet() || id == "" { + return nil + } + if _, ok := ipn.ParseAutoExitNodeString(id); ok { + // Not resolved yet, so there's no node to remember. + return nil } - return errors.Join(useErr, suggestErr) + _, err = localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ + ExitNodeID: id, + AutoExitNodeSet: true, + ExitNodeIDSet: true, + ExitNodeIPSet: true, + }) + if err != nil { + return fmt.Errorf("edit prefs: %w", err) + } + return nil +} + +func useAutoExitNode(ctx context.Context) error { + _, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ + AutoExitNode: ipn.AnyExitNode, + AutoExitNodeSet: true, + }) + if err != nil { + return fmt.Errorf("edit prefs: %w", err) + } + return nil +} + +func useSuggestedExitNode(ctx context.Context) error { + suggested, err := localClient.SuggestExitNode(ctx) + if err != nil { + return fmt.Errorf("suggest exit node: %w", err) + } + slog.Info("got suggested exit node", "id", suggested.ID, "name", suggested.Name, "location", suggested.Location) + return ExitNode(ctx, suggested.ID) +} + +// keepPreviousExitNode pins the exit node that the daemon just +// restored. Turning exit node use off while automatic selection was on +// makes the daemon remember that, so turning it back on would resume +// automatic selection instead of using the previous node. +func keepPreviousExitNode(ctx context.Context) error { + prefs, err := Prefs(ctx) + if err != nil { + return fmt.Errorf("get prefs: %w", err) + } + if !prefs.AutoExitNode.IsSet() { + return nil + } + + id := prefs.ExitNodeID + // The daemon uses this placeholder until it has picked a node. + if id == "" || id == "auto:any" { + suggested, err := localClient.SuggestExitNode(ctx) + if err != nil { + return fmt.Errorf("suggest exit node: %w", err) + } + id = suggested.ID + } + + _, err = localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ + ExitNodeID: id, + AutoExitNodeSet: true, + ExitNodeIDSet: true, + ExitNodeIPSet: true, + }) + if err != nil { + return fmt.Errorf("edit prefs: %w", err) + } + return nil } // AdvertiseExitNode enables and disables exit node advertisement for diff --git a/internal/tsutil/client_test.go b/internal/tsutil/client_test.go index 4cbb5f25..4b4e102a 100644 --- a/internal/tsutil/client_test.go +++ b/internal/tsutil/client_test.go @@ -21,9 +21,12 @@ func TestSetUseExitNodeFallback(t *testing.T) { wantRequests []string }{ { - name: "failed disable preserves the error", - wantError: true, - wantRequests: []string{"POST /localapi/v0/set-use-exit-node-enabled?enabled=false"}, + name: "failed disable preserves the error", + wantError: true, + wantRequests: []string{ + "GET /localapi/v0/prefs", + "POST /localapi/v0/set-use-exit-node-enabled?enabled=false", + }, }, { name: "failed enable still selects a suggested exit node", @@ -62,7 +65,7 @@ func TestSetUseExitNodeFallback(t *testing.T) { go srv.Serve(l) defer srv.Close() - err := SetUseExitNode(t.Context(), test.use) + err := SetUseExitNode(t.Context(), test.use, SelectPreviousExitNode) if test.wantError { require.ErrorContains(t, err, "disable denied") } else { @@ -106,3 +109,132 @@ func TestExitNodeClearsStaleIP(t *testing.T) { t.Fatal("no exit-node preference update received") } } + +// fakeExitNodeDaemon serves the parts of the LocalAPI that exit node +// selection uses, keeping prefs between requests. +type fakeExitNodeDaemon struct { + mu sync.Mutex + prefs ipn.Prefs + // restore is applied when exit node use is turned on. If it is + // zero, prior is restored the way the daemon does it. + restore ipn.Prefs + prior tailcfg.StableNodeID +} + +func (d *fakeExitNodeDaemon) ServeHTTP(w http.ResponseWriter, r *http.Request) { + d.mu.Lock() + defer d.mu.Unlock() + + switch r.URL.Path { + case "/localapi/v0/set-use-exit-node-enabled": + if r.URL.Query().Get("enabled") != "true" { + d.prior = d.prefs.ExitNodeID + if d.prefs.AutoExitNode.IsSet() { + d.prior = tailcfg.StableNodeID(ipn.AutoExitNodePrefix + d.prefs.AutoExitNode) + } + d.prefs.ExitNodeID = "" + d.prefs.AutoExitNode = "" + return + } + if d.restore.ExitNodeID != "" { + d.prefs.ExitNodeID = d.restore.ExitNodeID + d.prefs.AutoExitNode = d.restore.AutoExitNode + return + } + d.prefs.ExitNodeID = d.prior + if expr, ok := ipn.ParseAutoExitNodeString(d.prior); ok { + d.prefs.AutoExitNode = expr + d.prefs.ExitNodeID = "auto:any" + } + case "/localapi/v0/suggest-exit-node": + json.NewEncoder(w).Encode(apitype.ExitNodeSuggestionResponse{ID: "suggested"}) + case "/localapi/v0/prefs": + if r.Method == http.MethodPatch { + var edit ipn.MaskedPrefs + if err := json.NewDecoder(r.Body).Decode(&edit); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + d.prefs.ApplyEdits(&edit) + if edit.AutoExitNodeSet && edit.AutoExitNode.IsSet() && d.prefs.ExitNodeID == "" { + d.prefs.ExitNodeID = "auto:any" + } + } + json.NewEncoder(w).Encode(d.prefs) + default: + http.NotFound(w, r) + } +} + +func TestSetUseExitNodeSelection(t *testing.T) { + tests := []struct { + name string + sel ExitNodeSelection + restore ipn.Prefs + wantID tailcfg.StableNodeID + wantAuto ipn.ExitNodeExpression + }{ + { + name: "recommended turns on automatic selection", + sel: SelectRecommendedExitNode, + restore: ipn.Prefs{ExitNodeID: "previous"}, + wantID: "auto:any", + wantAuto: ipn.AnyExitNode, + }, + { + name: "previous keeps the restored node", + sel: SelectPreviousExitNode, + restore: ipn.Prefs{ExitNodeID: "previous"}, + wantID: "previous", + }, + { + name: "previous leaves restored automatic selection", + sel: SelectPreviousExitNode, + restore: ipn.Prefs{ExitNodeID: "resolved", AutoExitNode: ipn.AnyExitNode}, + wantID: "resolved", + }, + { + name: "previous pins a node when automatic selection is unresolved", + sel: SelectPreviousExitNode, + restore: ipn.Prefs{ExitNodeID: "auto:any", AutoExitNode: ipn.AnyExitNode}, + wantID: "suggested", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + d := &fakeExitNodeDaemon{restore: test.restore} + srv := &http.Server{Handler: d} + l := listenLocalAPI(t) + go srv.Serve(l) + defer srv.Close() + + require.NoError(t, SetUseExitNode(t.Context(), true, test.sel)) + d.mu.Lock() + defer d.mu.Unlock() + require.Equal(t, test.wantID, d.prefs.ExitNodeID) + require.Equal(t, test.wantAuto, d.prefs.AutoExitNode) + }) + } +} + +func TestSetUseExitNodePreviousAfterAutomatic(t *testing.T) { + d := &fakeExitNodeDaemon{prefs: ipn.Prefs{ExitNodeID: "resolved", AutoExitNode: ipn.AnyExitNode}} + srv := &http.Server{Handler: d} + l := listenLocalAPI(t) + go srv.Serve(l) + defer srv.Close() + + require.NoError(t, SetUseExitNode(t.Context(), false, SelectPreviousExitNode)) + require.NoError(t, SetUseExitNode(t.Context(), true, SelectPreviousExitNode)) + d.mu.Lock() + defer d.mu.Unlock() + require.Equal(t, tailcfg.StableNodeID("resolved"), d.prefs.ExitNodeID) + require.False(t, d.prefs.AutoExitNode.IsSet()) +} + +func TestParseExitNodeSelection(t *testing.T) { + for _, sel := range []ExitNodeSelection{SelectPreviousExitNode, SelectRecommendedExitNode} { + require.Equal(t, sel, ParseExitNodeSelection(sel.String())) + } + require.Equal(t, SelectPreviousExitNode, ParseExitNodeSelection("bogus")) +} diff --git a/internal/tsutil/watch_test.go b/internal/tsutil/watch_test.go index 1d797fd2..8bf22160 100644 --- a/internal/tsutil/watch_test.go +++ b/internal/tsutil/watch_test.go @@ -127,6 +127,14 @@ func TestMain(m *testing.M) { } fakeSocket = filepath.Join(dir, "tailscaled.sock") localClient.Socket, localClient.UseSocketOnly = fakeSocket, true + // Each test serves the socket with its own server and closes it + // afterward, so a pooled connection would outlive its server. + localClient.Transport = &http.Transport{ + DisableKeepAlives: true, + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return new(net.Dialer).DialContext(ctx, "unix", fakeSocket) + }, + } code := m.Run() os.RemoveAll(dir) os.Exit(code) diff --git a/internal/ui/app.go b/internal/ui/app.go index 3117ed2f..fa88a61c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -411,7 +411,7 @@ func (a *App) onAppActivate(ctx context.Context) { defer cancel() s := gutil.Ptr[glib.Variant](state).GetBoolean() - err := tsutil.SetUseExitNode(ctx, s) + err := tsutil.SetUseExitNode(ctx, s, a.exitNodeSelection()) if err != nil { slog.Error("failed to set exit node state", "state", s, "err", err) if a.win != nil { @@ -546,13 +546,14 @@ func (a *App) initTray(ctx context.Context) { OnConnToggle: func() { a.toggleConn(ctx) }, OnExitToggle: func() { + sel := a.exitNodeSelection() go func() { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() s := <-a.poller.GetIPN() toggle := !s.ExitNodeActive() - err := tsutil.SetUseExitNode(ctx, toggle) + err := tsutil.SetUseExitNode(ctx, toggle, sel) gutil.IdleAdd(func() { if err != nil { a.notify(locale.Get("Toggle exit node"), err.Error()) diff --git a/internal/ui/mainwindow.go b/internal/ui/mainwindow.go index 99ab4ac2..81ab1e85 100644 --- a/internal/ui/mainwindow.go +++ b/internal/ui/mainwindow.go @@ -533,6 +533,30 @@ func (win *MainWindow) restack(order []string) { win.restoreVisible(visible) } +// ShowPage makes the named page visible, closing the peer search if it +// hides the page. It returns false if there is no such page. +func (win *MainWindow) ShowPage(name string) bool { + if win.pages[name] == nil { + return false + } + if !slices.Contains(win.stackPageNames(), name) { + win.PeerSearchBar.SetSearchMode(false) + } + win.PeersStack.SetVisibleChildName(name) + return win.PeersStack.GetVisibleChildName() == name +} + +// ShowMullvadNode shows the Mullvad page with the given exit node +// revealed. +func (win *MainWindow) ShowMullvadNode(id tailcfg.StableNodeID) bool { + page, ok := win.pages["mullvad"].(*MullvadPage) + if !ok || !win.ShowPage("mullvad") { + return false + } + page.Reveal(id) + return true +} + func (win *MainWindow) restoreVisible(name string) { for _, vp := range win.viewStackPages() { if vp.name == name { diff --git a/internal/ui/mullvadpage.go b/internal/ui/mullvadpage.go index c0b61550..28c3fdf7 100644 --- a/internal/ui/mullvadpage.go +++ b/internal/ui/mullvadpage.go @@ -1,6 +1,8 @@ package ui import ( + "time" + "cmp" "context" _ "embed" @@ -226,6 +228,39 @@ func (page *MullvadPage) applySearch() { page.LocationList.InvalidateSort() } +// expanderRevealDelay is long enough for an AdwExpanderRow's reveal +// animation to finish. +const expanderRevealDelay = 300 * time.Millisecond + +// Reveal expands the location containing the given exit node and +// scrolls it into view. +func (page *MullvadPage) Reveal(id tailcfg.StableNodeID) { + node := page.exitNodes[id] + if node == nil { + return + } + if page.searching() { + page.SearchEntry.SetText("") + page.setQuery("") + } + focus := func() { + if page.exitNodes[id] == node { + node.row.GrabFocus() + } + } + if loc := page.locations[node.country]; loc != nil && !node.flat && !loc.GetExpanded() { + loc.SetExpanded(true) + // Focusing the row is what scrolls it into view, but while + // the expander's reveal animation runs, the row has no real + // size yet, so focusing it then only scrolls to the expander. + gutil.TimeoutAdd(expanderRevealDelay, focus) + return + } + // The row can't take focus until the newly visible page has been + // mapped. + gutil.IdleAdd(focus) +} + func (page *MullvadPage) setNodeTitle(node *mullvadExitNodeRow, searching bool) { loc := node.peer.Hostinfo().Location() if searching { diff --git a/internal/ui/preferences.go b/internal/ui/preferences.go index 6a789673..4e2ebb95 100644 --- a/internal/ui/preferences.go +++ b/internal/ui/preferences.go @@ -16,6 +16,7 @@ type PreferencesDialog struct { UseTrayIconRow *adw.SwitchRow PollingIntervalRow *adw.SpinRow PollingIntervalAdjustment *gtk.Adjustment + ExitNodeSelectionRow *adw.ComboRow TaildropAutoSaveRow *adw.SwitchRow TaildropAutoSaveFolderButton *gtk.Button } diff --git a/internal/ui/preferences.ui b/internal/ui/preferences.ui index 97449722..d0b7a735 100644 --- a/internal/ui/preferences.ui +++ b/internal/ui/preferences.ui @@ -31,6 +31,20 @@ Polling Interval + + + + + + Previous exit node + Recommended exit node + + + + How an exit node is chosen when turning it on from the menu + Exit Node + + diff --git a/internal/ui/selfpage.go b/internal/ui/selfpage.go index ec7cc32e..b84e94b9 100644 --- a/internal/ui/selfpage.go +++ b/internal/ui/selfpage.go @@ -36,6 +36,8 @@ type SelfPage struct { Page *adw.StatusPage IPList *gtk.ListBox OptionsGroup *adw.PreferencesGroup + ExitNodeRow *adw.ActionRow + ExitNodeIcon *gtk.Image AdvertiseExitNodeRow *adw.SwitchRow AllowLANAccessRow *adw.SwitchRow AcceptRoutesRow *adw.SwitchRow @@ -72,6 +74,7 @@ type SelfPage struct { fileModel *listmodels.Model[apitype.WaitingFile] incomingFiles int + exitNode tailcfg.NodeView latencyRows rowManager[xiter.Pair[string, time.Duration]] } @@ -120,6 +123,10 @@ func (page *SelfPage) init(a *App, status *tsutil.IPNStatus) { files.Unref() setPlaceholder(page.FilesList, locale.Get("No incoming files.")) + gutil.Connect(page.ExitNodeRow, page.ExitNodeRow.ConnectActivated, func(adw.ActionRow) { + page.showExitNode() + }) + page.connectSwitch(page.AdvertiseExitNodeRow, "advertise exit node", func(s bool) error { if s { err := tsutil.ExitNode(context.TODO(), "") @@ -396,6 +403,7 @@ func (page *SelfPage) UpdateIPN(status *tsutil.IPNStatus) bool { page.Page.SetTitle(page.peer.Hostinfo().Hostname()) page.Page.SetDescription(page.peer.Name()) + page.updateExitNodeRow(status) setSwitch(page.AdvertiseExitNodeRow, status.Prefs.AdvertisesExitNode()) setSwitch(page.AllowLANAccessRow, status.Prefs.ExitNodeAllowLANAccess()) setSwitch(page.AcceptRoutesRow, status.Prefs.RouteAll()) @@ -439,3 +447,37 @@ func compareWaitingFiles(f1, f2 apitype.WaitingFile) int { cmp.Compare(f1.Size, f2.Size), ) } + +func (page *SelfPage) updateExitNodeRow(status *tsutil.IPNStatus) { + page.exitNode = status.ExitNode() + switch { + case page.exitNode.Valid() && tsutil.IsMullvad(page.exitNode): + page.ExitNodeRow.SetSubtitle(mullvadLongLocationName(page.exitNode.Hostinfo().Location())) + case page.exitNode.Valid(): + page.ExitNodeRow.SetSubtitle(peerName(page.exitNode)) + case status.Prefs.ExitNodeIP().IsValid(): + page.ExitNodeRow.SetSubtitle(status.Prefs.ExitNodeIP().String()) + case status.Prefs.AutoExitNode().IsSet(): + page.ExitNodeRow.SetSubtitle(locale.Get("Waiting for an exit node")) + case status.Prefs.ExitNodeID() != "": + page.ExitNodeRow.SetSubtitle(locale.Get("Unavailable")) + default: + page.ExitNodeRow.SetSubtitle(locale.Get("None")) + } + + // Sharee nodes have no page to go to. + activatable := page.exitNode.Valid() && !tsutil.IsShareeNode(page.exitNode) + page.ExitNodeRow.SetActivatable(activatable) + page.ExitNodeIcon.SetVisible(activatable) +} + +func (page *SelfPage) showExitNode() { + if !page.exitNode.Valid() || page.app.win == nil { + return + } + if tsutil.IsMullvad(page.exitNode) { + page.app.win.ShowMullvadNode(page.exitNode.StableID()) + return + } + page.app.win.ShowPage(string(page.exitNode.StableID())) +} diff --git a/internal/ui/selfpage.ui b/internal/ui/selfpage.ui index aee2b95f..a11b296b 100644 --- a/internal/ui/selfpage.ui +++ b/internal/ui/selfpage.ui @@ -25,6 +25,18 @@ Options + + + None + Exit node + + + go-next-symbolic + False + + + + Allow this machine to be used as an exit node diff --git a/internal/ui/selfpage_test.go b/internal/ui/selfpage_test.go new file mode 100644 index 00000000..20b57ce9 --- /dev/null +++ b/internal/ui/selfpage_test.go @@ -0,0 +1,100 @@ +package ui + +import ( + "net/netip" + "testing" + + "deedles.dev/trayscale/internal/tsutil" + "github.com/stretchr/testify/require" + "tailscale.com/ipn" + "tailscale.com/tailcfg" +) + +func TestSelfPageExitNodeRow(t *testing.T) { + requireGTK(t) + + gateway := layoutPeer(t, "gateway", "gateway", true, true, false) + sharee := layoutPeer(t, "sharee", "shared", true, true, true) + + loc := &tailcfg.Location{Country: "Sweden", CountryCode: "SE", City: "Stockholm"} + mullvadNode := gateway.AsStruct() + mullvadNode.StableID = "mullvad" + mullvadNode.Tags = []string{"tag:mullvad-exit-node"} + hi := mullvadNode.Hostinfo.AsStruct() + hi.Location = loc + mullvadNode.Hostinfo = hi.View() + mullvad := mullvadNode.View() + + status := func(prefs ipn.Prefs, peers ...tailcfg.NodeView) *tsutil.IPNStatus { + s := layoutStatus(peers) + s.Prefs = prefs.View() + return s + } + + tests := []struct { + name string + status *tsutil.IPNStatus + wantSubtitle string + wantActivatable bool + }{ + { + name: "no exit node", + status: status(ipn.Prefs{}, gateway), + wantSubtitle: "None", + }, + { + name: "peer", + status: status(ipn.Prefs{ExitNodeID: "gateway"}, gateway), + wantSubtitle: peerName(gateway), + wantActivatable: true, + }, + { + name: "sharee has no page", + status: status(ipn.Prefs{ExitNodeID: "sharee"}, sharee), + wantSubtitle: peerName(sharee), + }, + { + name: "IP not among peers", + status: status(ipn.Prefs{ExitNodeIP: netip.MustParseAddr("100.64.0.9")}, gateway), + wantSubtitle: "100.64.0.9", + }, + { + name: "peer not in netmap", + status: status(ipn.Prefs{ExitNodeID: "removed"}, gateway), + wantSubtitle: "Unavailable", + }, + { + name: "automatic selection without a node yet", + status: status(ipn.Prefs{AutoExitNode: ipn.AnyExitNode, ExitNodeID: "auto:any"}, gateway), + wantSubtitle: "Waiting for an exit node", + }, + { + name: "automatic selection after its node left the netmap", + status: status(ipn.Prefs{AutoExitNode: ipn.AnyExitNode, ExitNodeID: "removed"}, gateway), + wantSubtitle: "Waiting for an exit node", + }, + { + name: "automatic selection resolved", + status: status(ipn.Prefs{AutoExitNode: ipn.AnyExitNode, ExitNodeID: "gateway"}, gateway), + wantSubtitle: peerName(gateway), + wantActivatable: true, + }, + { + name: "Mullvad", + status: status(ipn.Prefs{ExitNodeID: "mullvad"}, mullvad), + wantSubtitle: mullvadLongLocationName(loc.View()), + wantActivatable: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + page := NewSelfPage(&App{}, &tsutil.IPNStatus{State: ipn.Running, Prefs: ipn.NewPrefs().View()}) + defer page.Destroy() + + page.updateExitNodeRow(test.status) + require.Equal(t, test.wantSubtitle, page.ExitNodeRow.GetSubtitle()) + require.Equal(t, test.wantActivatable, page.ExitNodeRow.GetActivatable()) + require.Equal(t, test.wantActivatable, page.ExitNodeIcon.GetVisible()) + }) + } +} diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 9552c7ac..05506f61 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -141,6 +141,13 @@ func (a *App) showPreferences() { a.settings.Bind("polling-interval", &dialog.PollingIntervalAdjustment.Object, "value", gio.GSettingsBindDefaultValue) a.settings.Bind("taildrop-auto-save", &dialog.TaildropAutoSaveRow.Object, "active", gio.GSettingsBindDefaultValue) + // The row's items are in ExitNodeSelection order. + dialog.ExitNodeSelectionRow.SetSelected(uint32(a.exitNodeSelection())) + gutil.ConnectNotify(dialog.ExitNodeSelectionRow, "selected", func() { + sel := tsutil.ExitNodeSelection(dialog.ExitNodeSelectionRow.GetSelected()) + a.settings.SetString("exit-node-selection", sel.String()) + }) + updateAutoSaveSubtitle := func() { dir := gutil.SettingsString(a.settings, "taildrop-auto-save-dir") if dir == "" { @@ -242,3 +249,12 @@ func (a *App) getInterval() time.Duration { } return time.Duration(a.settings.GetDouble("polling-interval") * float64(time.Second)) } + +// exitNodeSelection returns how to pick an exit node when exit node +// use is turned on. +func (a *App) exitNodeSelection() tsutil.ExitNodeSelection { + if a.settings == nil { + return tsutil.SelectPreviousExitNode + } + return tsutil.ParseExitNodeSelection(gutil.SettingsString(a.settings, "exit-node-selection")) +} diff --git a/internal/ui/trayscale.cmb b/internal/ui/trayscale.cmb index 8e53fb4e..cdef8265 100644 --- a/internal/ui/trayscale.cmb +++ b/internal/ui/trayscale.cmb @@ -4,8 +4,8 @@ - - + +