Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/drive-trayscale/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>.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 `<name>.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.

Expand Down
33 changes: 21 additions & 12 deletions .agents/skills/drive-trayscale/scripts/tsharness/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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()
Expand All @@ -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})
Expand Down
1 change: 1 addition & 0 deletions .agents/skills/verify-trayscale/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions .agents/skills/verify-trayscale/features/automatic-exit-node.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .agents/skills/verify-trayscale/features/this-machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
------------

Expand Down
122 changes: 122 additions & 0 deletions internal/tsutil/automatic_exit_node_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
}
31 changes: 25 additions & 6 deletions internal/tsutil/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
Loading
Loading