From b04b4f7751aec581f6d57dfa884175991c0b7fb0 Mon Sep 17 00:00:00 2001 From: Codinget Date: Mon, 13 Apr 2026 18:43:01 +0000 Subject: [PATCH] feat(tsconnect): expose exit node selection to JS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add exit node support to the wasm JS bridge: - Include `exitNodeOption` and `stableNodeID` on each peer in the notifyNetMap payload so callers can identify which peers are exit nodes and reference them by stable ID. - Call `notifyExitNode(stableNodeID)` whenever prefs change, so callers can track which exit node (if any) is currently active. - Expose `setExitNode(stableNodeID)` — sets ExitNodeID via EditPrefs. - Expose `setExitNodeEnabled(enabled)` — toggles the last-used exit node on/off via SetUseExitNodeEnabled. Co-Authored-By: Claude Sonnet 4.6 --- cmd/tsconnect/wasm/wasm_js.go | 44 +++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 603d099ca..8a8ea1bb6 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -45,6 +45,7 @@ import ( "tailscale.com/logtail" "tailscale.com/net/bakedroots" "tailscale.com/net/netns" + "tailscale.com/net/tsaddr" "tailscale.com/net/tsdial" "tailscale.com/safesocket" "tailscale.com/tailcfg" @@ -252,6 +253,20 @@ func newIPN(jsConfig js.Value) map[string]any { } return jsIPN.dialTLS(args[0].String(), opts) }), + "setExitNode": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 1 { + log.Printf("Usage: setExitNode(stableNodeID)") + return nil + } + return jsIPN.setExitNode(args[0].String()) + }), + "setExitNodeEnabled": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 1 { + log.Printf("Usage: setExitNodeEnabled(enabled)") + return nil + } + return jsIPN.setExitNodeEnabled(args[0].Bool()) + }), } } @@ -333,6 +348,8 @@ func (i *jsIPN) run(jsCallbacks js.Value) { }, Online: p.Online().Clone(), TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(), + ExitNodeOption: tsaddr.ContainsExitRoutes(p.AllowedIPs()), + StableNodeID: string(p.StableID()), } }), LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0, @@ -343,6 +360,9 @@ func (i *jsIPN) run(jsCallbacks js.Value) { log.Printf("Could not generate JSON netmap: %v", err) } } + if n.Prefs.Valid() { + jsCallbacks.Call("notifyExitNode", string(n.Prefs.ExitNodeID())) + } if n.BrowseToURL != nil { jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL) } @@ -576,6 +596,24 @@ func (i *jsIPN) fetch(url string) js.Value { }) } +func (i *jsIPN) setExitNode(stableNodeID string) js.Value { + return makePromise(func() (any, error) { + mp := &ipn.MaskedPrefs{ + ExitNodeIDSet: true, + Prefs: ipn.Prefs{ExitNodeID: tailcfg.StableNodeID(stableNodeID)}, + } + _, err := i.lb.EditPrefs(mp) + return nil, err + }) +} + +func (i *jsIPN) setExitNodeEnabled(enabled bool) js.Value { + return makePromise(func() (any, error) { + _, err := i.lb.SetUseExitNodeEnabled(ipnauth.Self, enabled) + return nil, err + }) +} + func (i *jsIPN) dial(network, addr string) js.Value { return makePromise(func() (any, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -875,8 +913,10 @@ type jsNetMapSelfNode struct { type jsNetMapPeerNode struct { jsNetMapNode - Online *bool `json:"online,omitempty"` - TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"` + Online *bool `json:"online,omitempty"` + TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"` + ExitNodeOption bool `json:"exitNodeOption"` + StableNodeID string `json:"stableNodeID"` } type jsStateStore struct {