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 @@ - +