From 04164026bd54c12b5a890b8ba7c55dc73ce56e7f Mon Sep 17 00:00:00 2001 From: itsAnchorpoint <83099125+itsAnchorpoint@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:18:04 +0300 Subject: [PATCH] internal/ui: add automatic exit-node selection Expose tailscaled's automatic exit-node mode on the self page, together with the current node. Keep selection in the daemon so it survives closing Trayscale. Leaving automatic mode preserves a resolved node; explicitly selecting or clearing a node disables automatic selection. Apply changes off the GTK main loop, cancel pending work when the page is destroyed, and keep old status updates from cancelling toggle animations. Add preference, error-handling, UI-state, and animation regression tests. Extend tsharness with opt-in recommendation eligibility and document the feature and its isolated verification path. Co-authored-by: Codex --- .agents/skills/drive-trayscale/SKILL.md | 2 +- .../drive-trayscale/scripts/tsharness/main.go | 33 +++-- .../verify-trayscale/features/README.md | 1 + .../features/automatic-exit-node.md | 42 ++++++ .../verify-trayscale/features/this-machine.md | 2 + README.md | 2 + internal/tsutil/automatic_exit_node_test.go | 122 ++++++++++++++++++ internal/tsutil/client.go | 31 ++++- internal/ui/automatic_exit_node_test.go | 89 +++++++++++++ internal/ui/selfpage.go | 78 ++++++++++- internal/ui/selfpage.ui | 11 ++ internal/ui/trayscale.cmb | 2 +- 12 files changed, 393 insertions(+), 22 deletions(-) create mode 100644 .agents/skills/verify-trayscale/features/automatic-exit-node.md create mode 100644 internal/tsutil/automatic_exit_node_test.go create mode 100644 internal/ui/automatic_exit_node_test.go diff --git a/.agents/skills/drive-trayscale/SKILL.md b/.agents/skills/drive-trayscale/SKILL.md index 667fcb5f..6e639977 100644 --- a/.agents/skills/drive-trayscale/SKILL.md +++ b/.agents/skills/drive-trayscale/SKILL.md @@ -147,7 +147,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 automatic recommendations; `-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..5a8408ce 100644 --- a/.agents/skills/drive-trayscale/scripts/tsharness/main.go +++ b/.agents/skills/drive-trayscale/scripts/tsharness/main.go @@ -42,15 +42,16 @@ 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 } const capFileSharing = tailcfg.NodeCapability(tailcfg.CapabilityFileSharing) @@ -158,10 +159,16 @@ 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) } } @@ -274,6 +281,7 @@ 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") flag.Parse() ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() @@ -288,9 +296,10 @@ 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, } 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..7b3e6d94 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. +- [Automatic exit node](./automatic-exit-node.md) covers automatic selection, the current node, and returning to a manual selection. - [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/automatic-exit-node.md b/.agents/skills/verify-trayscale/features/automatic-exit-node.md new file mode 100644 index 00000000..597a3c34 --- /dev/null +++ b/.agents/skills/verify-trayscale/features/automatic-exit-node.md @@ -0,0 +1,42 @@ +# Automatic exit node + +The this-machine page lets Tailscale choose the exit node automatically and shows the current node. Turning automatic selection off keeps the current node selected. Choosing a node manually cancels automatic selection. + +## Sub-features + +- `automatic-enable` turns on automatic selection by Tailscale. +- `automatic-current` shows the selected node, or `Waiting for an exit node` while none is selected. +- `automatic-disable` stops automatic selection and keeps the current node. +- `automatic-manual` switches to a manually chosen node. +- `automatic-off` disables exit-node use from the existing app action. + +## How to get to it (user POV) + +- Open the self page under `This machine` and use `Automatic exit node` in `Options`. +- Select an exit node's page and turn on `Use as exit node` for a manual choice. +- Use the main menu's exit-node toggle to stop using an exit node. + +## Driving it with control-trayscale + +Preconditions: + +- `doctor` reports `frame 'Trayscale'` and `gsettings_backend` equal to `memory`. +- Before every preference change, `check-fake-tailnet` must report `FAKE`. This recipe permits `Automatic exit node`, `Use as exit node`, and `use_exit_node` only against tsharness. Never use these steps against the user's real tailnet. +- The test node is connected and does not advertise itself as an exit node. Start on its self page. +- Run tsharness with `-suggest-exit-nodes` for a resolved automatic choice, or without it to exercise waiting for a recommendation. + +- **Enable.** Save `before.tree.txt`, then `click --role switch --name "Automatic exit node" --exact --index 1`. Save `enabled.tree.txt`; the switch is checked and `Current exit node` names the chosen node or says `Waiting for an exit node`. Check `tailscale debug prefs` against the harness socket: `AutoExitNode` is `any`. +- **Disable.** Click the same switch and save `manual-current.tree.txt`. `AutoExitNode` is empty and the selected `ExitNodeID` remains unchanged when a node was selected. +- **Manual choice.** Enable Automatic again. Search for a different exit node (for example `echo-down`) and open its page, then `click --role switch --name "Use as exit node" --exact --index 1`. Check that `AutoExitNode` is empty and `ExitNodeID` names that peer. Restart the isolated app to return to the self page and capture the unchecked Automatic switch and current node. +- **Off.** While an exit node is in use, run `action use_exit_node --i-mean-it` and capture `off.tree.txt`. Both `AutoExitNode` and `ExitNodeID` are empty and `Current exit node` says `None`. +- **Persistence.** Enable Automatic, clean up the isolated app, and launch it again. Its Automatic switch remains checked because the setting belongs to Tailscale. +- **Resume.** With Automatic enabled, toggle `use_exit_node` off and on. Tailscale restores automatic selection; `AutoExitNode` is `any` again. + +## Gotchas + +- A selected server may change while Automatic is enabled. Assert the mode and displayed current node, rather than a fixed server name. +- The switch is unavailable while this machine advertises itself as an exit node. +- Turning Automatic off preserves a resolved node. If no node has been selected yet, there is none to preserve. +- 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. +- AT-SPI exposes the row and its child switch with the same name. Index 1 is the child with the `toggle` action. +- A toggle action starts an animation. Wait for the expected daemon preferences and checked state before navigating away; the AT-SPI action returning is not confirmation that the setting changed. diff --git a/.agents/skills/verify-trayscale/features/this-machine.md b/.agents/skills/verify-trayscale/features/this-machine.md index a72d1120..a58889b7 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 `Automatic exit node` and `Current exit node`. See [Automatic exit node](./automatic-exit-node.md) for testing the switch. - `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 `switch 'Automatic exit node'` and `Current exit node` with the selected node or `None`. Keep this path read-only; mode changes use the separate recipe above. - **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..416ccf54 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. +On this machine's page, **Automatic exit node** lets Tailscale track its [recommended exit node](https://tailscale.com/docs/features/exit-nodes/auto-exit-nodes), including while Trayscale is closed. Turning Automatic off keeps the current node selected; choosing another node manually also stops automatic selection. Use the existing **Use Exit Node** toggle to stop routing through an exit node entirely. + Installation ------------ diff --git a/internal/tsutil/automatic_exit_node_test.go b/internal/tsutil/automatic_exit_node_test.go new file mode 100644 index 00000000..e9143b71 --- /dev/null +++ b/internal/tsutil/automatic_exit_node_test.go @@ -0,0 +1,122 @@ +package tsutil + +import ( + "encoding/json" + "net/http" + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + "tailscale.com/ipn" + "tailscale.com/tailcfg" +) + +func TestAutomaticExitNodePreservesOtherPreferences(t *testing.T) { + tests := []struct { + name string + enable bool + prefs ipn.Prefs + }{ + {name: "enable without a selected node", enable: true}, + {name: "enable with a selected node", enable: true, prefs: ipn.Prefs{ExitNodeID: "current"}}, + {name: "disable keeps the current node", prefs: ipn.Prefs{AutoExitNode: ipn.AnyExitNode, ExitNodeID: "current"}}, + {name: "disable keeps a legacy IP", prefs: ipn.Prefs{AutoExitNode: ipn.AnyExitNode, ExitNodeIP: netip.MustParseAddr("100.64.0.1")}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.prefs.CorpDNS = true + tt.prefs.RouteAll = true + tt.prefs.WantRunning = true + tt.prefs.ExitNodeAllowLANAccess = true + updated := make(chan ipn.Prefs, 1) + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || r.URL.Path != "/localapi/v0/prefs" { + http.NotFound(w, r) + return + } + var edit ipn.MaskedPrefs + if err := json.NewDecoder(r.Body).Decode(&edit); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + prefs := tt.prefs + prefs.ApplyEdits(&edit) + updated <- prefs + json.NewEncoder(w).Encode(prefs) + })} + l := listenLocalAPI(t) + go srv.Serve(l) + defer srv.Close() + + require.NoError(t, SetAutomaticExitNode(t.Context(), tt.enable)) + want := tt.prefs + want.AutoExitNode = "" + if tt.enable { + want.AutoExitNode = ipn.AnyExitNode + } + require.Equal(t, want, <-updated) + }) + } +} + +func TestAutomaticExitNodeFailure(t *testing.T) { + tests := []struct { + name string + enable bool + status int + body string + want string + }{ + {name: "denied", enable: true, status: http.StatusForbidden, body: "permission denied", want: "permission denied"}, + {name: "unsupported by daemon", enable: true, status: http.StatusOK, body: `{}`, want: "did not apply"}, + {name: "enforced by policy", status: http.StatusOK, body: `{"AutoExitNode":"any"}`, want: "did not apply"}, + {name: "missing preferences", enable: true, status: http.StatusOK, body: `null`, want: "did not apply"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || r.URL.Path != "/localapi/v0/prefs" { + http.NotFound(w, r) + return + } + w.WriteHeader(tt.status) + w.Write([]byte(tt.body)) + })} + l := listenLocalAPI(t) + go srv.Serve(l) + defer srv.Close() + require.ErrorContains(t, SetAutomaticExitNode(t.Context(), tt.enable), tt.want) + }) + } +} + +func TestExitNodeLeavesAutomaticMode(t *testing.T) { + for _, selected := range []string{"manual", ""} { + t.Run("select "+selected, func(t *testing.T) { + updated := make(chan ipn.Prefs, 1) + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || r.URL.Path != "/localapi/v0/prefs" { + http.NotFound(w, r) + return + } + var edit ipn.MaskedPrefs + if err := json.NewDecoder(r.Body).Decode(&edit); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + prefs := ipn.Prefs{AutoExitNode: ipn.AnyExitNode, ExitNodeID: "automatic", ExitNodeIP: netip.MustParseAddr("100.64.0.1")} + prefs.ApplyEdits(&edit) + updated <- prefs + json.NewEncoder(w).Encode(prefs) + })} + l := listenLocalAPI(t) + go srv.Serve(l) + defer srv.Close() + require.NoError(t, ExitNode(t.Context(), tailcfg.StableNodeID(selected))) + prefs := <-updated + require.Empty(t, prefs.AutoExitNode) + require.EqualValues(t, selected, prefs.ExitNodeID) + require.False(t, prefs.ExitNodeIP.IsValid()) + }) + } +} diff --git a/internal/tsutil/client.go b/internal/tsutil/client.go index ecfce186..ffa92a59 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,6 +86,23 @@ func ExitNode(ctx context.Context, peer tailcfg.StableNodeID) error { return nil } +// SetAutomaticExitNode lets tailscaled track its recommended exit node. +// Disabling it keeps the current exit node selected, if there is one. +func SetAutomaticExitNode(ctx context.Context, enable bool) error { + edit := ipn.MaskedPrefs{AutoExitNodeSet: true} + if enable { + edit.AutoExitNode = ipn.AnyExitNode + } + prefs, err := localClient.EditPrefs(ctx, &edit) + if err != nil { + return fmt.Errorf("set automatic exit node: %w", err) + } + if prefs == nil || prefs.AutoExitNode != edit.AutoExitNode { + return errors.New("tailscaled did not apply automatic exit-node selection") + } + return nil +} + func SetUseExitNode(ctx context.Context, use bool) error { useErr := localClient.SetUseExitNode(ctx, use) if useErr == nil || !use { diff --git a/internal/ui/automatic_exit_node_test.go b/internal/ui/automatic_exit_node_test.go new file mode 100644 index 00000000..82276385 --- /dev/null +++ b/internal/ui/automatic_exit_node_test.go @@ -0,0 +1,89 @@ +package ui + +import ( + "net/netip" + "testing" + "time" + + "codeberg.org/puregotk/puregotk/v4/adw" + "codeberg.org/puregotk/puregotk/v4/gtk" + "deedles.dev/trayscale/internal/tsutil" + "tailscale.com/ipn" + "tailscale.com/tailcfg" +) + +func TestSelfPageAutomaticExitNode(t *testing.T) { + requireGTK(t) + + exit := layoutPeer(t, "exit", "gateway", true, true, false) + status := layoutStatus([]tailcfg.NodeView{exit}) + status.Prefs = ipn.NewPrefs().View() + page := NewSelfPage(&App{}, status) + defer page.Destroy() + stack := adw.NewViewStack() + defer stack.Unref() + stackPage := stack.AddNamed(page.Widget(), "self") + defer stackPage.Unref() + page.Bind(stackPage) + var advertising ipn.Prefs + advertising.SetAdvertiseExitNode(true) + + tests := []struct { + name string + prefs ipn.Prefs + wantAutomatic bool + wantSensitive bool + wantNode string + }{ + {name: "off", wantSensitive: true, wantNode: "None"}, + {name: "manual", prefs: ipn.Prefs{ExitNodeID: "exit"}, wantSensitive: true, wantNode: "gateway"}, + {name: "automatic", prefs: ipn.Prefs{AutoExitNode: ipn.AnyExitNode, ExitNodeID: "exit"}, wantAutomatic: true, wantSensitive: true, wantNode: "gateway"}, + {name: "waiting", prefs: ipn.Prefs{AutoExitNode: ipn.AnyExitNode, ExitNodeID: "auto:any"}, wantAutomatic: true, wantSensitive: true, wantNode: "Waiting for an exit node"}, + {name: "manual choice after automatic", prefs: ipn.Prefs{ExitNodeID: "exit"}, wantSensitive: true, wantNode: "gateway"}, + {name: "missing peer", prefs: ipn.Prefs{ExitNodeID: "missing"}, wantSensitive: true, wantNode: "Unavailable"}, + {name: "legacy IP", prefs: ipn.Prefs{ExitNodeIP: netip.MustParseAddr("100.64.0.1")}, wantSensitive: true, wantNode: "100.64.0.1"}, + {name: "advertising", prefs: advertising, wantNode: "None"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status.Prefs = tt.prefs.View() + page.updateExitNode(status) + sw := rowSwitch(page.AutomaticExitNodeRow) + if sw.GetActive() != tt.wantAutomatic || sw.GetState() != tt.wantAutomatic { + t.Errorf("automatic active=%v state=%v, want %v", sw.GetActive(), sw.GetState(), tt.wantAutomatic) + } + if got := page.AutomaticExitNodeRow.GetSensitive(); got != tt.wantSensitive { + t.Errorf("sensitive = %v, want %v", got, tt.wantSensitive) + } + if got := page.CurrentExitNodeRow.GetSubtitle(); got != tt.wantNode { + t.Errorf("current exit node = %q, want %q", got, tt.wantNode) + } + }) + } +} + +// An old daemon status must not cancel a click during the toggle animation, +// before the switch has emitted state-set and started the request. +func TestAutomaticExitNodeSwitchUpdates(t *testing.T) { + requireGTK(t) + + page := &SelfPage{AutomaticExitNodeRow: adw.NewSwitchRow(), CurrentExitNodeRow: adw.NewActionRow()} + defer page.AutomaticExitNodeRow.Unref() + defer page.CurrentExitNodeRow.Unref() + w := gtk.NewWindow() + defer w.Destroy() + list := gtk.NewListBox() + defer list.Unref() + list.Append(&page.AutomaticExitNodeRow.Widget) + w.SetChild(&list.Widget) + w.Present() + iterateFor(500 * time.Millisecond) + sw := rowSwitch(page.AutomaticExitNodeRow) + sw.Activate() + iterateFor(40 * time.Millisecond) + page.updateExitNode(&tsutil.IPNStatus{Prefs: ipn.NewPrefs().View()}) + iterateFor(300 * time.Millisecond) + if !sw.GetActive() { + t.Fatal("old status cancelled the automatic exit-node toggle") + } +} diff --git a/internal/ui/selfpage.go b/internal/ui/selfpage.go index ec7cc32e..ef050943 100644 --- a/internal/ui/selfpage.go +++ b/internal/ui/selfpage.go @@ -37,6 +37,8 @@ type SelfPage struct { IPList *gtk.ListBox OptionsGroup *adw.PreferencesGroup AdvertiseExitNodeRow *adw.SwitchRow + AutomaticExitNodeRow *adw.SwitchRow + CurrentExitNodeRow *adw.ActionRow AllowLANAccessRow *adw.SwitchRow AcceptRoutesRow *adw.SwitchRow AcceptDNSRow *adw.SwitchRow @@ -71,8 +73,11 @@ type SelfPage struct { routeModel *listmodels.Model[netip.Prefix] fileModel *listmodels.Model[apitype.WaitingFile] - incomingFiles int - latencyRows rowManager[xiter.Pair[string, time.Duration]] + incomingFiles int + latencyRows rowManager[xiter.Pair[string, time.Duration]] + automaticCancel context.CancelFunc + automaticExitNode bool + advertisesExitNode bool } func NewSelfPage(a *App, status *tsutil.IPNStatus) *SelfPage { @@ -139,6 +144,16 @@ func (page *SelfPage) init(a *App, status *tsutil.IPNStatus) { page.connectSwitch(page.AcceptDNSRow, "accept DNS", func(s bool) error { return tsutil.AcceptDNS(context.TODO(), s) }) + sw := rowSwitch(page.AutomaticExitNodeRow) + gutil.Connect(sw, sw.ConnectStateSet, func(_ gtk.Switch, enabled bool) bool { + if enabled == sw.GetState() { + return false + } + if page.automaticCancel == nil { + page.changeAutomaticExitNode(enabled) + } + return true + }) gutil.Connect(page.AdvertiseRouteButton, page.AdvertiseRouteButton.ConnectClicked, func(gtk.Button) { Prompt{ @@ -242,6 +257,29 @@ func (page *SelfPage) init(a *App, status *tsutil.IPNStatus) { }) } +func (page *SelfPage) changeAutomaticExitNode(enabled bool) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + page.automaticCancel = cancel + page.AutomaticExitNodeRow.SetSensitive(false) + go func() { + defer cancel() + err := tsutil.SetAutomaticExitNode(ctx, enabled) + gutil.IdleAdd(func() { + // Destroy releases the widgets and cancels any pending request. + if page.Page == nil { + return + } + page.automaticCancel = nil + if err != nil { + slog.Error("set automatic exit node", "err", err) + page.app.toast(locale.Get("Failed to change automatic exit-node selection")) + } + setSwitch(page.AutomaticExitNodeRow, page.automaticExitNode) + page.AutomaticExitNodeRow.SetSensitive(!page.advertisesExitNode) + }) + }() +} + // connectSwitch connects a switch row to set, which applies a new // state. On error the switch flips back. func (page *SelfPage) connectSwitch(row *adw.SwitchRow, what string, set func(bool) error) { @@ -355,6 +393,9 @@ func (page *SelfPage) Actions() *gio.SimpleActionGroup { } func (page *SelfPage) Destroy() { + if page.automaticCancel != nil { + page.automaticCancel() + } page.latencyRows.Update(nil) page.addrModel.Unref() page.routeModel.Unref() @@ -397,6 +438,7 @@ func (page *SelfPage) UpdateIPN(status *tsutil.IPNStatus) bool { page.Page.SetDescription(page.peer.Name()) setSwitch(page.AdvertiseExitNodeRow, status.Prefs.AdvertisesExitNode()) + page.updateExitNode(status) setSwitch(page.AllowLANAccessRow, status.Prefs.ExitNodeAllowLANAccess()) setSwitch(page.AcceptRoutesRow, status.Prefs.RouteAll()) setSwitch(page.AcceptDNSRow, status.Prefs.CorpDNS()) @@ -417,6 +459,38 @@ func (page *SelfPage) UpdateIPN(status *tsutil.IPNStatus) bool { return true } +func (page *SelfPage) updateExitNode(status *tsutil.IPNStatus) { + page.automaticExitNode = status.Prefs.AutoExitNode().IsSet() + page.advertisesExitNode = status.Prefs.AdvertisesExitNode() + if page.automaticCancel == nil { + sw := rowSwitch(page.AutomaticExitNodeRow) + sw.SetState(page.automaticExitNode) + // Setting Active even to its current value cancels an in-flight + // toggle animation before it can start the request. + if sw.GetActive() != page.automaticExitNode { + sw.SetActive(page.automaticExitNode) + } + } + page.AutomaticExitNodeRow.SetSensitive(page.automaticCancel == nil && !page.advertisesExitNode) + page.CurrentExitNodeRow.SetSubtitle(currentExitNodeName(status)) +} + +func currentExitNodeName(status *tsutil.IPNStatus) string { + if peer := status.ExitNode(); peer.Valid() { + return peerName(peer) + } + if addr := status.Prefs.ExitNodeIP(); addr.IsValid() { + return addr.String() + } + if status.Prefs.AutoExitNode().IsSet() { + return locale.Get("Waiting for an exit node") + } + if status.Prefs.ExitNodeID() != "" { + return locale.Get("Unavailable") + } + return locale.Get("None") +} + func (page *SelfPage) UpdateFiles(status *tsutil.FileStatus) bool { page.fileModel.Update(slices.Values(status.Files)) page.incomingFiles = len(status.Files) diff --git a/internal/ui/selfpage.ui b/internal/ui/selfpage.ui index aee2b95f..2a760f48 100644 --- a/internal/ui/selfpage.ui +++ b/internal/ui/selfpage.ui @@ -25,6 +25,17 @@ Options + + + Let Tailscale choose the exit node. Turning this off keeps the current node. + Automatic exit node + + + + + Current exit node + + Allow this machine to be used as an exit node diff --git a/internal/ui/trayscale.cmb b/internal/ui/trayscale.cmb index 8e53fb4e..3b3e6c14 100644 --- a/internal/ui/trayscale.cmb +++ b/internal/ui/trayscale.cmb @@ -5,7 +5,7 @@ - +