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)