From 7f5983eaabf2d18a7619856233fbf83b52871f62 Mon Sep 17 00:00:00 2001 From: Codinget Date: Sat, 9 May 2026 21:55:58 +0000 Subject: [PATCH 1/4] feat(tsconnect): add whoIs, queryDNS, ping, suggestExitNode WASM bindings Expose four LocalBackend capabilities to JavaScript: - whoIs(addrPort, proto?): resolves a connecting ip:port to a tailnet node and user profile; returns null for unknown peers - queryDNS(name, type?): queries the tailnet DNS resolver (MagicDNS + upstream); parses A/AAAA/CNAME/TXT answers into strings - ping(ip, type?, size?): pings a tailnet peer (TSMP, disco, ICMP, peerapi) with a 30 s context timeout; returns latency and path details - suggestExitNode(): asks the coordination server for the best exit node Co-Authored-By: Claude Sonnet 4.6 --- cmd/tsconnect/wasm/wasm_js.go | 191 ++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 423fb8024..9b080c1e2 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -30,6 +30,7 @@ import ( "time" "golang.org/x/crypto/ssh" + "golang.org/x/net/dns/dnsmessage" "gvisor.dev/gvisor/pkg/tcpip" "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" "gvisor.dev/gvisor/pkg/tcpip/network/ipv4" @@ -315,6 +316,46 @@ func newIPN(jsConfig js.Value) map[string]any { } return jsIPN.setFunnel(args[0].String(), uint16(args[1].Int()), args[2].Bool()) }), + "whoIs": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) < 1 { + log.Printf("Usage: whoIs(addrPort[, proto])") + return nil + } + proto := "" + if len(args) >= 2 { + proto = args[1].String() + } + return jsIPN.whoIs(args[0].String(), proto) + }), + "queryDNS": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) < 1 { + log.Printf("Usage: queryDNS(name[, type])") + return nil + } + qtype := 1 // TypeA + if len(args) >= 2 { + qtype = args[1].Int() + } + return jsIPN.queryDNS(args[0].String(), qtype) + }), + "ping": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) < 1 { + log.Printf("Usage: ping(ip[, type[, size]])") + return nil + } + pingType := "TSMP" + if len(args) >= 2 { + pingType = args[1].String() + } + size := 0 + if len(args) >= 3 { + size = args[2].Int() + } + return jsIPN.ping(args[0].String(), pingType, size) + }), + "suggestExitNode": js.FuncOf(func(this js.Value, args []js.Value) any { + return jsIPN.suggestExitNode() + }), } } @@ -1001,6 +1042,156 @@ func (i *jsIPN) setFunnel(hostname string, port uint16, enabled bool) js.Value { }) } +func (i *jsIPN) whoIs(addrPort string, proto string) js.Value { + return makePromise(func() (any, error) { + ipp, err := netip.ParseAddrPort(addrPort) + if err != nil { + return nil, fmt.Errorf("whoIs: invalid addr:port %q: %w", addrPort, err) + } + n, u, ok := i.lb.WhoIs(proto, ipp) + if !ok { + return nil, nil + } + addrs := make([]any, n.Addresses().Len()) + for idx, ap := range n.Addresses().All() { + addrs[idx] = ap.Addr().String() + } + return map[string]any{ + "node": map[string]any{ + "id": string(n.StableID()), + "name": n.Name(), + "addresses": addrs, + }, + "user": map[string]any{ + "id": int64(u.ID), + "loginName": u.LoginName, + "displayName": u.DisplayName, + "profilePicURL": u.ProfilePicURL, + }, + }, nil + }) +} + +func (i *jsIPN) queryDNS(name string, queryType int) js.Value { + return makePromise(func() (any, error) { + res, resolvers, err := i.lb.QueryDNS(name, dnsmessage.Type(queryType)) + if err != nil { + return nil, err + } + var p dnsmessage.Parser + if _, err := p.Start(res); err != nil { + return nil, fmt.Errorf("queryDNS: parsing response: %w", err) + } + if err := p.SkipAllQuestions(); err != nil { + return nil, fmt.Errorf("queryDNS: skipping questions: %w", err) + } + var answers []any + for { + h, err := p.AnswerHeader() + if err == dnsmessage.ErrSectionDone { + break + } + if err != nil { + return nil, fmt.Errorf("queryDNS: reading answer: %w", err) + } + switch h.Type { + case dnsmessage.TypeA: + r, err := p.AResource() + if err != nil { + return nil, fmt.Errorf("queryDNS: reading A record: %w", err) + } + answers = append(answers, netip.AddrFrom4(r.A).String()) + case dnsmessage.TypeAAAA: + r, err := p.AAAAResource() + if err != nil { + return nil, fmt.Errorf("queryDNS: reading AAAA record: %w", err) + } + answers = append(answers, netip.AddrFrom16(r.AAAA).String()) + case dnsmessage.TypeCNAME: + r, err := p.CNAMEResource() + if err != nil { + return nil, fmt.Errorf("queryDNS: reading CNAME record: %w", err) + } + answers = append(answers, r.CNAME.String()) + case dnsmessage.TypeTXT: + r, err := p.TXTResource() + if err != nil { + return nil, fmt.Errorf("queryDNS: reading TXT record: %w", err) + } + for _, s := range r.TXT { + answers = append(answers, s) + } + default: + if err := p.SkipAnswer(); err != nil { + return nil, fmt.Errorf("queryDNS: skipping unknown answer: %w", err) + } + } + } + resolverAddrs := make([]any, len(resolvers)) + for idx, r := range resolvers { + resolverAddrs[idx] = r.Addr + } + return map[string]any{ + "answers": answers, + "resolvers": resolverAddrs, + }, nil + }) +} + +func (i *jsIPN) ping(ip string, pingType string, size int) js.Value { + return makePromise(func() (any, error) { + addr, err := netip.ParseAddr(ip) + if err != nil { + return nil, fmt.Errorf("ping: invalid IP %q: %w", ip, err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + pr, err := i.lb.Ping(ctx, addr, tailcfg.PingType(pingType), size) + if err != nil { + return nil, err + } + result := map[string]any{ + "ip": pr.IP, + "nodeIP": pr.NodeIP, + "nodeName": pr.NodeName, + "latencySeconds": pr.LatencySeconds, + "endpoint": pr.Endpoint, + "derpRegionID": pr.DERPRegionID, + "derpRegionCode": pr.DERPRegionCode, + "peerAPIURL": pr.PeerAPIURL, + "isLocalIP": pr.IsLocalIP, + } + if pr.Err != "" { + result["err"] = pr.Err + } + return result, nil + }) +} + +func (i *jsIPN) suggestExitNode() js.Value { + return makePromise(func() (any, error) { + resp, err := i.lb.SuggestExitNode() + if err != nil { + return nil, err + } + result := map[string]any{ + "id": string(resp.ID), + "name": resp.Name, + } + if l := resp.Location; l.Valid() { + result["location"] = map[string]any{ + "country": l.Country(), + "countryCode": l.CountryCode(), + "city": l.City(), + "cityCode": l.CityCode(), + "latitude": l.Latitude(), + "longitude": l.Longitude(), + } + } + return result, 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{ -- 2.52.0 From 8514045909753cffb86cfa443c618d0a887db5a9 Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 10 May 2026 01:19:37 +0000 Subject: [PATCH 2/4] 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 { -- 2.52.0 From 7fd2507611556631fc470a4e362d8c7bbf5d4070 Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 10 May 2026 15:20:40 +0000 Subject: [PATCH 3/4] fix(wasm): validate ping type early; fallback DNS resolver for exit node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a switch guard before the 30-second context in ping() so that invalid ping type strings (e.g. "disco" vs "Disco") reject immediately with a clear error rather than silently timing out because userspaceEngine.Ping has no default case. For queryDNS(), detect SERVFAIL responses returned with an empty resolver list (the typical state when an exit node is active but the DNS manager forwarder has no configured upstreams) and fall back to querying 8.8.8.8 via the dialer — which honours exit-node routing — for A/AAAA record types. Fall further back to the browser's native resolver if UserDial fails. Also accept bare IP addresses in whoIs() (in addition to ip:port) so callers don't need to fabricate a port when they only have a peer IP. Co-Authored-By: Claude Sonnet 4.6 --- cmd/tsconnect/wasm/wasm_js.go | 75 ++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 5632fd404..8bc64e18e 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -1115,11 +1115,16 @@ func (i *jsIPN) setFunnel(hostname string, port uint16, enabled bool) js.Value { }) } -func (i *jsIPN) whoIs(addrPort string, proto string) js.Value { +func (i *jsIPN) whoIs(addr string, proto string) js.Value { return makePromise(func() (any, error) { - ipp, err := netip.ParseAddrPort(addrPort) - if err != nil { - return nil, fmt.Errorf("whoIs: invalid addr:port %q: %w", addrPort, err) + // Accept both "ip:port" and bare "ip" (port 0 still resolves by IP). + var ipp netip.AddrPort + if ap, err := netip.ParseAddrPort(addr); err == nil { + ipp = ap + } else if ip, err := netip.ParseAddr(addr); err == nil { + ipp = netip.AddrPortFrom(ip, 0) + } else { + return nil, fmt.Errorf("whoIs: invalid address %q (want ip:port or ip)", addr) } n, u, ok := i.lb.WhoIs(proto, ipp) if !ok { @@ -1148,9 +1153,61 @@ func (i *jsIPN) whoIs(addrPort string, proto string) js.Value { func (i *jsIPN) queryDNS(name string, queryType int) js.Value { return makePromise(func() (any, error) { res, resolvers, err := i.lb.QueryDNS(name, dnsmessage.Type(queryType)) - if err != nil { - return nil, err + + // Detect SERVFAIL with no upstream resolvers (common when an exit node is + // active but the DNS manager forwarder has no configured upstreams). Fall + // back to querying 8.8.8.8 via the dialer (which routes through the exit + // node), then as a last resort use the browser's default name resolver. + needsFallback := err != nil + if !needsFallback && len(resolvers) == 0 && len(res) > 0 { + var hdrParser dnsmessage.Parser + if hdr, hdrErr := hdrParser.Start(res); hdrErr == nil && hdr.RCode == dnsmessage.RCodeServerFailure { + needsFallback = true + } } + if needsFallback { + qt := dnsmessage.Type(queryType) + if qt != dnsmessage.TypeA && qt != dnsmessage.TypeAAAA { + if err != nil { + return nil, fmt.Errorf("queryDNS: %w (no upstream resolver; only A/AAAA queries support fallback)", err) + } + return nil, fmt.Errorf("queryDNS: no upstream resolver available; only A/AAAA queries support fallback lookup") + } + ctx := context.Background() + d := i.dialer + r := &net.Resolver{ + PreferGo: true, + Dial: func(rctx context.Context, network, address string) (net.Conn, error) { + return d.UserDial(rctx, "tcp", "8.8.8.8:53") + }, + } + ips, rerr := r.LookupIPAddr(ctx, name) + if rerr != nil { + // Last resort: browser-native resolution (no exit-node routing). + ips, rerr = (&net.Resolver{PreferGo: false}).LookupIPAddr(ctx, name) + if rerr != nil { + return nil, fmt.Errorf("queryDNS: fallback resolution failed: %w", rerr) + } + } + var answers []any + for _, ia := range ips { + ip, ok := netip.AddrFromSlice(ia.IP) + if !ok { + continue + } + ip = ip.Unmap() + if qt == dnsmessage.TypeA && ip.Is4() { + answers = append(answers, ip.String()) + } else if qt == dnsmessage.TypeAAAA && ip.Is6() { + answers = append(answers, ip.String()) + } + } + return map[string]any{ + "answers": answers, + "resolvers": []any{}, + }, nil + } + var p dnsmessage.Parser if _, err := p.Start(res); err != nil { return nil, fmt.Errorf("queryDNS: parsing response: %w", err) @@ -1217,6 +1274,12 @@ func (i *jsIPN) ping(ip string, pingType string, size int) js.Value { if err != nil { return nil, fmt.Errorf("ping: invalid IP %q: %w", ip, err) } + switch tailcfg.PingType(pingType) { + case tailcfg.PingDisco, tailcfg.PingTSMP, tailcfg.PingICMP, tailcfg.PingPeerAPI: + // valid + default: + return nil, fmt.Errorf("ping: unknown type %q, must be one of: disco, TSMP, icmp, peerapi", pingType) + } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() pr, err := i.lb.Ping(ctx, addr, tailcfg.PingType(pingType), size) -- 2.52.0 From 52cae45f8131b395a2cf9d1367017bc297a6b73b Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 10 May 2026 15:28:50 +0000 Subject: [PATCH 4/4] fix(wasm): correct ICMP case in ping type error message The constant tailcfg.PingICMP is "ICMP" not "icmp"; the error message was listing the wrong string, causing user confusion about valid values. Co-Authored-By: Claude Sonnet 4.6 --- cmd/tsconnect/wasm/wasm_js.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 8bc64e18e..d37921211 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -1278,7 +1278,7 @@ func (i *jsIPN) ping(ip string, pingType string, size int) js.Value { case tailcfg.PingDisco, tailcfg.PingTSMP, tailcfg.PingICMP, tailcfg.PingPeerAPI: // valid default: - return nil, fmt.Errorf("ping: unknown type %q, must be one of: disco, TSMP, icmp, peerapi", pingType) + return nil, fmt.Errorf("ping: unknown type %q, must be one of: disco, TSMP, ICMP, peerapi", pingType) } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() -- 2.52.0