feat(tsconnect): add whoIs, queryDNS, ping, suggestExitNode WASM bindings, peerAPI/localAPI access #5
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user