From 8514045909753cffb86cfa443c618d0a887db5a9 Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 10 May 2026 01:19:37 +0000 Subject: [PATCH] feat(tsconnect): add peerAPIURL to netmap and localAPI in-process bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include the PeerAPI base URL (http://ip:port) in every node entry of the notifyNetMap payload — for self via LocalBackend.GetPeerAPIPort, for peers by reading the PeerAPI4/PeerAPI6 Services entries in their Hostinfo. The URL mirrors the address-family preference used by peerAPIBase (prefer IPv4). Add a localAPI(method, path, body?) WASM binding that dispatches in-process HTTP requests directly to a LocalAPI handler with full read/write/cert permissions, returning {status, body}. Enables TypeScript callers to access any LocalAPI endpoint (ACL policy, Taildrive shares, etc.) without network setup. Co-Authored-By: Claude Sonnet 4.6 --- cmd/tsconnect/wasm/wasm_js.go | 129 ++++++++++++++++++++++++++++++++-- 1 file changed, 123 insertions(+), 6 deletions(-) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 9b080c1e2..5632fd404 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -18,10 +18,12 @@ import ( "encoding/json" "errors" "fmt" + "io" "log" "math/rand/v2" "net" "net/http" + "net/http/httptest" "net/netip" "strconv" "strings" @@ -42,6 +44,7 @@ import ( "tailscale.com/ipn/ipnauth" "tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnserver" + "tailscale.com/ipn/localapi" "tailscale.com/ipn/store/mem" "tailscale.com/logpolicy" "tailscale.com/logtail" @@ -52,6 +55,7 @@ import ( "tailscale.com/safesocket" "tailscale.com/tailcfg" "tailscale.com/tsd" + "tailscale.com/types/logid" "tailscale.com/types/views" "tailscale.com/wgengine" "tailscale.com/wgengine/netstack" @@ -173,6 +177,7 @@ func newIPN(jsConfig js.Value) map[string]any { controlURL: controlURL, authKey: authKey, hostname: hostname, + logID: logid, funnelPorts: make(map[uint16]*funnelListenerEntry), } lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP) @@ -356,6 +361,17 @@ func newIPN(jsConfig js.Value) map[string]any { "suggestExitNode": js.FuncOf(func(this js.Value, args []js.Value) any { return jsIPN.suggestExitNode() }), + "localAPI": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) < 2 { + log.Printf("Usage: localAPI(method, path[, body])") + return nil + } + body := "" + if len(args) >= 3 { + body = args[2].String() + } + return jsIPN.localAPI(args[0].String(), args[1].String(), body) + }), } } @@ -367,6 +383,7 @@ type jsIPN struct { controlURL string authKey string hostname string + logID logid.PublicID funnelMu sync.Mutex funnelPorts map[uint16]*funnelListenerEntry @@ -417,6 +434,31 @@ func (i *jsIPN) run(jsCallbacks js.Value) { notifyState(*n.State) } if nm := n.NetMap; nm != nil { + // Determine which address families we have, for peer peerAPI URL selection. + var selfHave4, selfHave6 bool + for _, a := range nm.GetAddresses().All() { + if !a.IsSingleIP() { + continue + } + if a.Addr().Is4() { + selfHave4 = true + } else if a.Addr().Is6() { + selfHave6 = true + } + } + + // Self peerAPI URL: own port as reported by LocalBackend. + selfPeerAPIURL := "" + for _, a := range nm.GetAddresses().All() { + if !a.IsSingleIP() { + continue + } + if port, ok := i.lb.GetPeerAPIPort(a.Addr()); ok && port != 0 { + selfPeerAPIURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), port)) + break + } + } + jsNetMap := jsNetMap{ Self: jsNetMapSelfNode{ jsNetMapNode: jsNetMapNode{ @@ -424,6 +466,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) { Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }), NodeKey: nm.NodeKey.String(), MachineKey: nm.MachineKey.String(), + PeerAPIURL: selfPeerAPIURL, }, MachineStatus: jsMachineStatus[nm.GetMachineStatus()], }, @@ -434,15 +477,45 @@ func (i *jsIPN) run(jsCallbacks js.Value) { name = p.Hostinfo().Hostname() } addrs := make([]string, p.Addresses().Len()) - for i, ap := range p.Addresses().All() { - addrs[i] = ap.Addr().String() + for idx, ap := range p.Addresses().All() { + addrs[idx] = ap.Addr().String() } + + // Peer peerAPI URL from the peer's advertised Services. + peerURL := "" + var pp4, pp6 uint16 + for _, s := range p.Hostinfo().Services().All() { + switch s.Proto { + case tailcfg.PeerAPI4: + pp4 = s.Port + case tailcfg.PeerAPI6: + pp6 = s.Port + } + } + if selfHave4 && pp4 != 0 { + for _, a := range p.Addresses().All() { + if a.IsSingleIP() && a.Addr().Is4() { + peerURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp4)) + break + } + } + } + if peerURL == "" && selfHave6 && pp6 != 0 { + for _, a := range p.Addresses().All() { + if a.IsSingleIP() && a.Addr().Is6() { + peerURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp6)) + break + } + } + } + return jsNetMapPeerNode{ jsNetMapNode: jsNetMapNode{ Name: name, Addresses: addrs, MachineKey: p.Machine().String(), NodeKey: p.Key().String(), + PeerAPIURL: peerURL, }, Online: p.Online().Clone(), TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(), @@ -1192,6 +1265,49 @@ func (i *jsIPN) suggestExitNode() js.Value { }) } +func (i *jsIPN) localAPI(method, path, body string) js.Value { + return makePromise(func() (any, error) { + h := localapi.NewHandler(localapi.HandlerConfig{ + Actor: &ipnauth.TestActor{ + Name: "wasm", + LocalAdmin: true, + }, + Backend: i.lb, + Logf: log.Printf, + LogID: i.logID, + EventBus: i.lb.Sys().Bus.Get(), + }) + h.PermitRead = true + h.PermitWrite = true + h.PermitCert = true + + var bodyReader io.Reader + if body != "" { + bodyReader = strings.NewReader(body) + } + req, err := http.NewRequest(method, "http://local-tailscaled.sock"+path, bodyReader) + if err != nil { + return nil, fmt.Errorf("localAPI: %w", err) + } + // Empty Host passes the validHost check in the LocalAPI handler. + req.Host = "" + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + resp := w.Result() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("localAPI: reading response: %w", err) + } + return map[string]any{ + "status": resp.StatusCode, + "body": string(respBody), + }, nil + }) +} + // wrapConn exposes a net.Conn to JavaScript with binary (Uint8Array) I/O. func wrapConn(conn net.Conn) map[string]any { return map[string]any{ @@ -1386,10 +1502,11 @@ type jsNetMap struct { } type jsNetMapNode struct { - Name string `json:"name"` - Addresses []string `json:"addresses"` - MachineKey string `json:"machineKey"` - NodeKey string `json:"nodeKey"` + Name string `json:"name"` + Addresses []string `json:"addresses"` + MachineKey string `json:"machineKey"` + NodeKey string `json:"nodeKey"` + PeerAPIURL string `json:"peerAPIURL,omitempty"` } type jsNetMapSelfNode struct {