From 7fd2507611556631fc470a4e362d8c7bbf5d4070 Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 10 May 2026 15:20:40 +0000 Subject: [PATCH] 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)