2 Commits

Author SHA1 Message Date
codinget 756ba1d5ec feat(tsconnect): expose dial, listen and listenICMP to JS
Wire up the userspace networking primitives to the JS bridge so
browser callers can initiate outbound and receive inbound traffic
over the Tailscale network:

- ipn.dial(network, addr) wraps a tsdial UserDial into a JS Conn
  with read/write/close/localAddr/remoteAddr.
- ipn.listen(network, addr) wraps a netstack ListenPacket into a
  JS PacketConn with readFrom/writeTo/close/localAddr.
- ipn.listenICMP("icmp4"|"icmp6"|"icmp") creates a raw ICMP
  endpoint on the underlying gVisor stack and wraps it as a
  PacketConn for sending/receiving ping traffic.

To support listenICMP, netstack.Impl gains a Stack() accessor that
returns the underlying *stack.Stack so jsIPN can call NewEndpoint
with icmp.ProtocolNumber4/6.

Binary I/O uses js.CopyBytesToGo / js.CopyBytesToJS to move bytes
across the syscall/js boundary without base64 round-trips.
2026-04-10 13:57:15 +00:00
codinget 68670f938b fix(tsconnect): drop nethttpomithttp2 build tag
After 1d93bdce2 ("control/controlclient: remove x/net/http2, use
net/http"), the noise control client uses net/http's Transport with
Protocols.SetUnencryptedHTTP2(true). The nethttpomithttp2 build tag
strips the bundled HTTP/2 implementation from net/http, so at runtime
the control client fails the first register request with "http:
Transport does not support unencrypted HTTP/2" and the wasm never
connects.

Drop the tag so the bundled HTTP/2 ships in the wasm binary.
2026-04-10 13:56:59 +00:00
3 changed files with 192 additions and 1 deletions
+1 -1
View File
@@ -228,7 +228,7 @@ func buildWasm(dev bool) ([]byte, error) {
// to fail for unclosed files.
defer outputFile.Close()
args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,nethttpomithttp2,omitidna,omitpemdecrypt"}
args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,omitidna,omitpemdecrypt"}
if !dev {
if *devControl != "" {
return nil, fmt.Errorf("Development control URL can only be used in dev mode.")
+186
View File
@@ -20,11 +20,18 @@ import (
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"syscall/js"
"time"
"golang.org/x/crypto/ssh"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
"gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
"gvisor.dev/gvisor/pkg/waiter"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
@@ -154,6 +161,7 @@ func newIPN(jsConfig js.Value) map[string]any {
dialer: dialer,
srv: srv,
lb: lb,
ns: ns,
controlURL: controlURL,
authKey: authKey,
hostname: hostname,
@@ -208,6 +216,27 @@ func newIPN(jsConfig js.Value) map[string]any {
url := args[0].String()
return jsIPN.fetch(url)
}),
"dial": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 2 {
log.Printf("Usage: dial(network, addr)")
return nil
}
return jsIPN.dial(args[0].String(), args[1].String())
}),
"listen": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 2 {
log.Printf("Usage: listen(network, addr)")
return nil
}
return jsIPN.listen(args[0].String(), args[1].String())
}),
"listenICMP": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: listenICMP(network)")
return nil
}
return jsIPN.listenICMP(args[0].String())
}),
}
}
@@ -215,6 +244,7 @@ type jsIPN struct {
dialer *tsdial.Dialer
srv *ipnserver.Server
lb *ipnlocal.LocalBackend
ns *netstack.Impl
controlURL string
authKey string
hostname string
@@ -531,6 +561,162 @@ func (i *jsIPN) fetch(url string) js.Value {
})
}
func (i *jsIPN) dial(network, addr string) js.Value {
return makePromise(func() (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
conn, err := i.dialer.UserDial(ctx, network, addr)
if err != nil {
return nil, err
}
return wrapConn(conn), nil
})
}
func (i *jsIPN) listen(network, addr string) js.Value {
return makePromise(func() (any, error) {
pc, err := i.ns.ListenPacket(network, addr)
if err != nil {
return nil, err
}
return wrapPacketConn(pc), nil
})
}
func (i *jsIPN) listenICMP(network string) js.Value {
return makePromise(func() (any, error) {
var transportProto tcpip.TransportProtocolNumber
var networkProto tcpip.NetworkProtocolNumber
switch network {
case "icmp4", "icmp":
transportProto = icmp.ProtocolNumber4
networkProto = ipv4.ProtocolNumber
case "icmp6":
transportProto = icmp.ProtocolNumber6
networkProto = ipv6.ProtocolNumber
default:
return nil, fmt.Errorf("unsupported network %q (use \"icmp4\" or \"icmp6\")", network)
}
st := i.ns.Stack()
var wq waiter.Queue
ep, nserr := st.NewEndpoint(transportProto, networkProto, &wq)
if nserr != nil {
return nil, fmt.Errorf("creating ICMP endpoint: %v", nserr)
}
pc := gonet.NewUDPConn(&wq, ep)
return wrapPacketConn(pc), 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{
"read": js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
buf := make([]byte, 65536)
n, err := conn.Read(buf)
if err != nil {
return nil, err
}
arr := js.Global().Get("Uint8Array").New(n)
js.CopyBytesToJS(arr, buf[:n])
return arr, nil
})
}),
"write": js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
data := args[0]
buf := make([]byte, data.Get("length").Int())
js.CopyBytesToGo(buf, data)
n, err := conn.Write(buf)
if err != nil {
return nil, err
}
return n, nil
})
}),
"close": js.FuncOf(func(this js.Value, args []js.Value) any {
return conn.Close() != nil
}),
"localAddr": js.FuncOf(func(this js.Value, args []js.Value) any {
return conn.LocalAddr().String()
}),
"remoteAddr": js.FuncOf(func(this js.Value, args []js.Value) any {
return conn.RemoteAddr().String()
}),
}
}
// wrapPacketConn exposes a net.PacketConn to JavaScript with binary (Uint8Array) I/O.
func wrapPacketConn(pc net.PacketConn) map[string]any {
return map[string]any{
"readFrom": js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
buf := make([]byte, 65536)
n, addr, err := pc.ReadFrom(buf)
if err != nil {
return nil, err
}
arr := js.Global().Get("Uint8Array").New(n)
js.CopyBytesToJS(arr, buf[:n])
return map[string]any{
"data": arr,
"addr": addr.String(),
}, nil
})
}),
"writeTo": js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
data := args[0]
addrStr := args[1].String()
buf := make([]byte, data.Get("length").Int())
js.CopyBytesToGo(buf, data)
addr, err := resolveUDPAddr(addrStr)
if err != nil {
return nil, err
}
n, err := pc.WriteTo(buf, addr)
if err != nil {
return nil, err
}
return n, nil
})
}),
"close": js.FuncOf(func(this js.Value, args []js.Value) any {
return pc.Close() != nil
}),
"localAddr": js.FuncOf(func(this js.Value, args []js.Value) any {
return pc.LocalAddr().String()
}),
}
}
// resolveUDPAddr parses an address string that is either "host:port" or just
// an IP (for ICMP, where port defaults to 0).
func resolveUDPAddr(s string) (*net.UDPAddr, error) {
host, portStr, err := net.SplitHostPort(s)
if err != nil {
// Bare IP address without port (used for ICMP).
ip := net.ParseIP(s)
if ip == nil {
return nil, fmt.Errorf("invalid address: %s", s)
}
return &net.UDPAddr{IP: ip}, nil
}
ip := net.ParseIP(host)
if ip == nil {
return nil, fmt.Errorf("invalid IP: %s", host)
}
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("invalid port: %s", portStr)
}
return &net.UDPAddr{IP: ip, Port: port}, nil
}
type termWriter struct {
f js.Value
}
+5
View File
@@ -280,6 +280,11 @@ type Impl struct {
packetsInFlight map[stack.TransportEndpointID]struct{}
}
// Stack returns the underlying gVisor network stack.
func (ns *Impl) Stack() *stack.Stack {
return ns.ipstack
}
const nicID = 1
// maxUDPPacketSize is the maximum size of a UDP packet we copy in