diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 423fb8024..d37921211 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" @@ -30,6 +32,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" @@ -41,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" @@ -51,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" @@ -172,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) @@ -315,6 +321,57 @@ 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() + }), + "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) + }), } } @@ -326,6 +383,7 @@ type jsIPN struct { controlURL string authKey string hostname string + logID logid.PublicID funnelMu sync.Mutex funnelPorts map[uint16]*funnelListenerEntry @@ -376,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{ @@ -383,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()], }, @@ -393,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(), @@ -1001,6 +1115,262 @@ func (i *jsIPN) setFunnel(hostname string, port uint16, enabled bool) js.Value { }) } +func (i *jsIPN) whoIs(addr string, proto string) js.Value { + return makePromise(func() (any, error) { + // 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 { + 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)) + + // 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) + } + 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) + } + 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) + 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 + }) +} + +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{ @@ -1195,10 +1565,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 {