// Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause // The wasm package builds a WebAssembly module that provides a subset of // Tailscale APIs to JavaScript. // // When run in the browser, a newIPN(config) function is added to the global JS // namespace. When called it returns an ipn object with the methods // run(callbacks), login(), logout(), and ssh(...). package main import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "math/rand/v2" "net" "net/http" "net/http/httptest" "net/netip" "strconv" "strings" "sync" "syscall/js" "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" "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" "tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnserver" "tailscale.com/ipn/localapi" "tailscale.com/ipn/store/mem" "tailscale.com/logpolicy" "tailscale.com/logtail" "tailscale.com/net/bakedroots" "tailscale.com/net/netns" "tailscale.com/net/tsaddr" "tailscale.com/net/tsdial" "tailscale.com/safesocket" "tailscale.com/tailcfg" "tailscale.com/tsd" "tailscale.com/types/logid" "tailscale.com/types/views" "tailscale.com/wgengine" "tailscale.com/wgengine/netstack" "tailscale.com/words" ) // ControlURL defines the URL to be used for connection to Control. var ControlURL = ipn.DefaultControlURL func main() { js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 1 { log.Fatal("Usage: newIPN(config)") return nil } return newIPN(args[0]) })) // Keep Go runtime alive, otherwise it will be shut down before newIPN gets // called. <-make(chan bool) } func newIPN(jsConfig js.Value) map[string]any { netns.SetEnabled(false) var store ipn.StateStore if jsStateStorage := jsConfig.Get("stateStorage"); !jsStateStorage.IsUndefined() { store = &jsStateStore{jsStateStorage} } else { store = new(mem.Store) } controlURL := ControlURL if jsControlURL := jsConfig.Get("controlURL"); jsControlURL.Type() == js.TypeString { controlURL = jsControlURL.String() } var authKey string if jsAuthKey := jsConfig.Get("authKey"); jsAuthKey.Type() == js.TypeString { authKey = jsAuthKey.String() } var hostname string if jsHostname := jsConfig.Get("hostname"); jsHostname.Type() == js.TypeString { hostname = jsHostname.String() } else { hostname = generateHostname() } lpc := getOrCreateLogPolicyConfig(store) c := logtail.Config{ Collection: lpc.Collection, PrivateID: lpc.PrivateID, // Compressed requests set HTTP headers that are not supported by the // no-cors fetching mode: CompressLogs: false, HTTPC: &http.Client{Transport: &noCORSTransport{http.DefaultTransport}}, } logtail := logtail.NewLogger(c, log.Printf) logf := logtail.Logf sys := tsd.NewSystem() sys.Set(store) dialer := &tsdial.Dialer{Logf: logf} dialer.SetBus(sys.Bus.Get()) eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{ Dialer: dialer, SetSubsystem: sys.Set, ControlKnobs: sys.ControlKnobs(), HealthTracker: sys.HealthTracker.Get(), ExtraRootCAs: sys.ExtraRootCAs, Metrics: sys.UserMetricsRegistry(), EventBus: sys.Bus.Get(), }) if err != nil { log.Fatal(err) } sys.Set(eng) ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper()) if err != nil { log.Fatalf("netstack.Create: %v", err) } sys.Set(ns) ns.ProcessLocalIPs = true ns.ProcessSubnets = true dialer.UseNetstackForIP = func(ip netip.Addr) bool { return true } dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { return ns.DialContextTCP(ctx, dst) } dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { return ns.DialContextUDP(ctx, dst) } sys.NetstackRouter.Set(true) sys.Tun.Get().Start() logid := lpc.PublicID srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get()) lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral) if err != nil { log.Fatalf("ipnlocal.NewLocalBackend: %v", err) } if err := ns.Start(lb); err != nil { log.Fatalf("failed to start netstack: %v", err) } wireTaildropFileOps(lb, jsConfig.Get("fileOps")) srv.SetLocalBackend(lb) jsIPN := &jsIPN{ dialer: dialer, srv: srv, lb: lb, ns: ns, controlURL: controlURL, authKey: authKey, hostname: hostname, logID: logid, funnelPorts: make(map[uint16]*funnelListenerEntry), } lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP) return map[string]any{ "run": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 1 { log.Fatal(`Usage: run({ notifyState(state: int): void, notifyNetMap(netMap: object): void, notifyBrowseToURL(url: string): void, notifyPanicRecover(err: string): void, })`) return nil } jsIPN.run(args[0]) return nil }), "login": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 0 { log.Printf("Usage: login()") return nil } jsIPN.login() return nil }), "logout": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 0 { log.Printf("Usage: logout()") return nil } jsIPN.logout() return nil }), "ssh": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 3 { log.Printf("Usage: ssh(hostname, userName, termConfig)") return nil } return jsIPN.ssh( args[0].String(), args[1].String(), args[2]) }), "fetch": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 1 { log.Printf("Usage: fetch(url)") return nil } 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()) }), "dialTLS": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) < 1 || len(args) > 2 { log.Printf("Usage: dialTLS(addr, opts?)") return nil } var opts js.Value if len(args) == 2 { opts = args[1] } return jsIPN.dialTLS(args[0].String(), opts) }), "setExitNode": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 1 { log.Printf("Usage: setExitNode(stableNodeID)") return nil } return jsIPN.setExitNode(args[0].String()) }), "setExitNodeEnabled": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 1 { log.Printf("Usage: setExitNodeEnabled(enabled)") return nil } return jsIPN.setExitNodeEnabled(args[0].Bool()) }), "listFileTargets": js.FuncOf(func(this js.Value, args []js.Value) any { return jsIPN.listFileTargets() }), "sendFile": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 3 { log.Printf("Usage: sendFile(stableNodeID, filename, data)") return nil } return jsIPN.sendFile(args[0].String(), args[1].String(), args[2]) }), "waitingFiles": js.FuncOf(func(this js.Value, args []js.Value) any { return jsIPN.waitingFiles() }), "openWaitingFile": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 1 { log.Printf("Usage: openWaitingFile(name)") return nil } return jsIPN.openWaitingFile(args[0].String()) }), "deleteWaitingFile": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 1 { log.Printf("Usage: deleteWaitingFile(name)") return nil } return jsIPN.deleteWaitingFile(args[0].String()) }), "getCert": js.FuncOf(func(this js.Value, args []js.Value) any { return jsIPN.getCert() }), "listenTLS": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 3 { log.Printf("Usage: listenTLS(addr, certPEM, keyPEM)") return nil } return jsIPN.listenTLS(args[0].String(), args[1].String(), args[2].String()) }), "setFunnel": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 3 { log.Printf("Usage: setFunnel(hostname, port, enabled)") return nil } 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) }), } } type jsIPN struct { dialer *tsdial.Dialer srv *ipnserver.Server lb *ipnlocal.LocalBackend ns *netstack.Impl controlURL string authKey string hostname string logID logid.PublicID funnelMu sync.Mutex funnelPorts map[uint16]*funnelListenerEntry } // funnelListenerEntry is the per-port state for routing Funnel connections to a listenTLS listener. type funnelListenerEntry struct { ch chan net.Conn tlsCfg *tls.Config } var jsIPNState = map[ipn.State]string{ ipn.NoState: "NoState", ipn.InUseOtherUser: "InUseOtherUser", ipn.NeedsLogin: "NeedsLogin", ipn.NeedsMachineAuth: "NeedsMachineAuth", ipn.Stopped: "Stopped", ipn.Starting: "Starting", ipn.Running: "Running", } var jsMachineStatus = map[tailcfg.MachineStatus]string{ tailcfg.MachineUnknown: "MachineUnknown", tailcfg.MachineUnauthorized: "MachineUnauthorized", tailcfg.MachineAuthorized: "MachineAuthorized", tailcfg.MachineInvalid: "MachineInvalid", } func (i *jsIPN) run(jsCallbacks js.Value) { notifyState := func(state ipn.State) { jsCallbacks.Call("notifyState", jsIPNState[state]) } notifyState(ipn.NoState) i.lb.SetNotifyCallback(func(n ipn.Notify) { // Panics in the notify callback are likely due to be due to bugs in // this bridging module (as opposed to actual bugs in Tailscale) and // thus may be recoverable. Let the UI know, and allow the user to // choose if they want to reload the page. defer func() { if r := recover(); r != nil { fmt.Println("Panic recovered:", r) jsCallbacks.Call("notifyPanicRecover", fmt.Sprint(r)) } }() log.Printf("NOTIFY: %+v", n) if n.State != nil { notifyState(*n.State) } if n.SelfChange != nil { // Self changed: rebuild the JS-side NetMap snapshot. Peers // don't ride on the bus anymore, so fetch them on demand // from LocalBackend. nm := i.lb.NetMapWithPeers() if 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{ Name: nm.SelfName(), 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()], }, Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode { name := p.Name() if name == "" { // In practice this should only happen for Hello. name = p.Hostinfo().Hostname() } addrs := make([]string, p.Addresses().Len()) for i, ap := range p.Addresses().All() { addrs[i] = 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(), ExitNodeOption: tsaddr.ContainsExitRoutes(p.AllowedIPs()), StableNodeID: string(p.StableID()), } }), LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0, } if jsonNetMap, err := json.Marshal(jsNetMap); err == nil { jsCallbacks.Call("notifyNetMap", string(jsonNetMap)) } else { log.Printf("Could not generate JSON netmap: %v", err) } } } if n.Prefs != nil && n.Prefs.Valid() { jsCallbacks.Call("notifyExitNode", string(n.Prefs.ExitNodeID())) } if n.BrowseToURL != nil { jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL) } if n.FilesWaiting != nil { jsCallbacks.Call("notifyFilesWaiting") } if n.IncomingFiles != nil { files := make([]jsIncomingFile, len(n.IncomingFiles)) for i, f := range n.IncomingFiles { files[i] = jsIncomingFile{ Name: f.Name, Started: f.Started.UnixMilli(), DeclaredSize: f.DeclaredSize, Received: f.Received, Done: f.Done, } } if b, err := json.Marshal(files); err == nil { jsCallbacks.Call("notifyIncomingFiles", string(b)) } else { log.Printf("could not marshal IncomingFiles: %v", err) } } if n.OutgoingFiles != nil { files := make([]jsOutgoingFile, len(n.OutgoingFiles)) for i, f := range n.OutgoingFiles { files[i] = jsOutgoingFile{ ID: f.ID, PeerID: string(f.PeerID), Name: f.Name, Started: f.Started.UnixMilli(), DeclaredSize: f.DeclaredSize, Sent: f.Sent, Finished: f.Finished, Succeeded: f.Succeeded, } } if b, err := json.Marshal(files); err == nil { jsCallbacks.Call("notifyOutgoingFiles", string(b)) } else { log.Printf("could not marshal OutgoingFiles: %v", err) } } }) go func() { err := i.lb.Start(ipn.Options{ UpdatePrefs: &ipn.Prefs{ ControlURL: i.controlURL, RouteAll: false, WantRunning: true, Hostname: i.hostname, }, AuthKey: i.authKey, }) if err != nil { log.Printf("Start error: %v", err) } }() go func() { ln, err := safesocket.Listen("") if err != nil { log.Fatalf("safesocket.Listen: %v", err) } err = i.srv.Run(context.Background(), ln) log.Fatalf("ipnserver.Run exited: %v", err) }() } func (i *jsIPN) login() { go i.lb.StartLoginInteractive(context.Background()) } func (i *jsIPN) logout() { if i.lb.State() == ipn.NoState { log.Printf("Backend not running") } go func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() i.lb.Logout(ctx, ipnauth.Self) }() } func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any { jsSSHSession := &jsSSHSession{ jsIPN: i, host: host, username: username, termConfig: termConfig, } go jsSSHSession.Run() return map[string]any{ "close": js.FuncOf(func(this js.Value, args []js.Value) any { return jsSSHSession.Close() != nil }), "resize": js.FuncOf(func(this js.Value, args []js.Value) any { rows := args[0].Int() cols := args[1].Int() return jsSSHSession.Resize(rows, cols) != nil }), } } type jsSSHSession struct { jsIPN *jsIPN host string username string termConfig js.Value session *ssh.Session pendingResizeRows int pendingResizeCols int } func (s *jsSSHSession) Run() { writeFn := s.termConfig.Get("writeFn") writeErrorFn := s.termConfig.Get("writeErrorFn") setReadFn := s.termConfig.Get("setReadFn") rows := s.termConfig.Get("rows").Int() cols := s.termConfig.Get("cols").Int() timeoutSeconds := 5.0 if jsTimeoutSeconds := s.termConfig.Get("timeoutSeconds"); jsTimeoutSeconds.Type() == js.TypeNumber { timeoutSeconds = jsTimeoutSeconds.Float() } onConnectionProgress := s.termConfig.Get("onConnectionProgress") onConnected := s.termConfig.Get("onConnected") onDone := s.termConfig.Get("onDone") defer onDone.Invoke() writeError := func(label string, err error) { writeErrorFn.Invoke(fmt.Sprintf("%s Error: %v\r\n", label, err)) } reportProgress := func(message string) { onConnectionProgress.Invoke(message) } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds*float64(time.Second))) defer cancel() reportProgress(fmt.Sprintf("Connecting to %s…", strings.Split(s.host, ".")[0])) c, err := s.jsIPN.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.host, "22")) if err != nil { writeError("Dial", err) return } defer c.Close() config := &ssh.ClientConfig{ HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { // Host keys are not used with Tailscale SSH, but we can use this // callback to know that the connection has been established. reportProgress("SSH connection established…") return nil }, User: s.username, } reportProgress("Starting SSH client…") sshConn, _, _, err := ssh.NewClientConn(c, s.host, config) if err != nil { writeError("SSH Connection", err) return } defer sshConn.Close() sshClient := ssh.NewClient(sshConn, nil, nil) defer sshClient.Close() session, err := sshClient.NewSession() if err != nil { writeError("SSH Session", err) return } s.session = session defer session.Close() stdin, err := session.StdinPipe() if err != nil { writeError("SSH Stdin", err) return } session.Stdout = termWriter{writeFn} session.Stderr = termWriter{writeFn} setReadFn.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any { input := args[0].String() _, err := stdin.Write([]byte(input)) if err != nil { writeError("Write Input", err) } return nil })) // We might have gotten a resize notification since we started opening the // session, pick up the latest size. if s.pendingResizeRows != 0 { rows = s.pendingResizeRows } if s.pendingResizeCols != 0 { cols = s.pendingResizeCols } err = session.RequestPty("xterm", rows, cols, ssh.TerminalModes{}) if err != nil { writeError("Pseudo Terminal", err) return } err = session.Shell() if err != nil { writeError("Shell", err) return } onConnected.Invoke() err = session.Wait() if err != nil { writeError("Wait", err) return } } func (s *jsSSHSession) Close() error { if s.session == nil { // We never had a chance to open the session, ignore the close request. return nil } return s.session.Close() } func (s *jsSSHSession) Resize(rows, cols int) error { if s.session == nil { s.pendingResizeRows = rows s.pendingResizeCols = cols return nil } return s.session.WindowChange(rows, cols) } func (i *jsIPN) fetch(url string) js.Value { return makePromise(func() (any, error) { c := &http.Client{ Transport: &http.Transport{ DialContext: i.dialer.UserDial, }, } res, err := c.Get(url) if err != nil { return nil, err } return map[string]any{ "status": res.StatusCode, "statusText": res.Status, "text": js.FuncOf(func(this js.Value, args []js.Value) any { return makePromise(func() (any, error) { defer res.Body.Close() buf := new(bytes.Buffer) if _, err := buf.ReadFrom(res.Body); err != nil { return nil, err } return buf.String(), nil }) }), // TODO: populate a more complete JS Response object }, nil }) } func (i *jsIPN) setExitNode(stableNodeID string) js.Value { return makePromise(func() (any, error) { mp := &ipn.MaskedPrefs{ ExitNodeIDSet: true, Prefs: ipn.Prefs{ExitNodeID: tailcfg.StableNodeID(stableNodeID)}, } _, err := i.lb.EditPrefs(mp) return nil, err }) } func (i *jsIPN) setExitNodeEnabled(enabled bool) js.Value { return makePromise(func() (any, error) { _, err := i.lb.SetUseExitNodeEnabled(ipnauth.Self, enabled) return nil, err }) } 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) { switch network { case "tcp", "tcp4", "tcp6": // netstack.ListenTCP only accepts tcp4/tcp6; bare "tcp" // defaults to IPv4 to match net.Listen's typical behavior // when given an unspecified address. n := network if n == "tcp" { n = "tcp4" } ln, err := i.ns.ListenTCP(n, addr) if err != nil { return nil, err } return wrapTCPListener(ln), nil case "udp", "udp4", "udp6": pc, err := i.ns.ListenPacket(network, addr) if err != nil { return nil, err } return wrapPacketConn(pc), nil default: return nil, fmt.Errorf("unsupported network %q", network) } }) } func (i *jsIPN) dialTLS(addr string, opts js.Value) js.Value { return makePromise(func() (any, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() host, _, err := net.SplitHostPort(addr) if err != nil { return nil, fmt.Errorf("invalid address %q: %w", addr, err) } // On wasm there's no system root pool, so default to the // baked-in LetsEncrypt roots (which is what `tailscale cert` // uses for tailnet HTTPS endpoints). Callers can override with // caCerts (PEM) or bypass entirely with insecureSkipVerify. cfg := &tls.Config{ ServerName: host, RootCAs: bakedroots.Get(), } if !opts.IsUndefined() && !opts.IsNull() { if sn := opts.Get("serverName"); sn.Type() == js.TypeString { cfg.ServerName = sn.String() } if iv := opts.Get("insecureSkipVerify"); iv.Type() == js.TypeBoolean { cfg.InsecureSkipVerify = iv.Bool() } if ca := opts.Get("caCerts"); ca.Type() == js.TypeString { pool := x509.NewCertPool() if !pool.AppendCertsFromPEM([]byte(ca.String())) { return nil, fmt.Errorf("caCerts: no valid PEM certificates found") } cfg.RootCAs = pool } } rawConn, err := i.dialer.UserDial(ctx, "tcp", addr) if err != nil { return nil, err } tlsConn := tls.Client(rawConn, cfg) if err := tlsConn.HandshakeContext(ctx); err != nil { rawConn.Close() return nil, err } return wrapConn(tlsConn), 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 }) } func (i *jsIPN) getCert() js.Value { return makePromise(func() (any, error) { nm := i.lb.NetMap() if nm == nil { return nil, errors.New("getCert: no network map available") } certDomains := nm.DNS.CertDomains if len(certDomains) == 0 { return nil, errors.New("getCert: this tailnet does not support TLS certificates") } pair, err := i.lb.GetCertPEM(context.Background(), certDomains[0]) if err != nil { return nil, err } return map[string]any{ "certPEM": string(pair.CertPEM), "keyPEM": string(pair.KeyPEM), }, nil }) } func (i *jsIPN) listenTLS(addr, certPEM, keyPEM string) js.Value { return makePromise(func() (any, error) { cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)) if err != nil { return nil, fmt.Errorf("listenTLS: parsing cert/key: %w", err) } tlsCfg := &tls.Config{Certificates: []tls.Certificate{cert}} tcpLn, err := i.ns.ListenTCP("tcp4", addr) if err != nil { return nil, err } // Determine the actual port (handles ":0" ephemeral assignment). // Use SplitHostPort rather than netip.ParseAddrPort because gVisor // may return ":443" (empty host) which ParseAddrPort rejects. _, portStr, err := net.SplitHostPort(tcpLn.Addr().String()) if err != nil { tcpLn.Close() return nil, fmt.Errorf("listenTLS: getting port from listener addr: %w", err) } portNum, err := strconv.ParseUint(portStr, 10, 16) if err != nil { tcpLn.Close() return nil, fmt.Errorf("listenTLS: parsing port %q: %w", portStr, err) } port := uint16(portNum) // Register a Funnel entry so handleFunnelTCP can route to this listener. entry := &funnelListenerEntry{ ch: make(chan net.Conn, 8), tlsCfg: tlsCfg, } i.funnelMu.Lock() i.funnelPorts[port] = entry i.funnelMu.Unlock() ln := newCombinedTLSListener(tcpLn, tlsCfg, entry.ch, port, i) return wrapTCPListener(ln), nil }) } // handleFunnelTCP is registered with LocalBackend.SetTCPHandlerForFunnelFlow. // It routes incoming Funnel connections to the matching listenTLS listener. func (i *jsIPN) handleFunnelTCP(src netip.AddrPort, dstPort uint16) func(net.Conn) { i.funnelMu.Lock() entry := i.funnelPorts[dstPort] i.funnelMu.Unlock() if entry == nil { return nil } return func(conn net.Conn) { tlsConn := tls.Server(conn, entry.tlsCfg) select { case entry.ch <- tlsConn: default: // Channel full; drop the connection rather than block. conn.Close() } } } // combinedTLSListener merges TLS connections from the local netstack (direct // tailnet access) and from Funnel ingress into a single net.Listener. type combinedTLSListener struct { tcpLn net.Listener tlsCfg *tls.Config funnelCh <-chan net.Conn port uint16 ipn *jsIPN netstackCh chan net.Conn errCh chan error done chan struct{} closeOnce sync.Once } func newCombinedTLSListener(tcpLn net.Listener, tlsCfg *tls.Config, funnelCh <-chan net.Conn, port uint16, ipn *jsIPN) *combinedTLSListener { l := &combinedTLSListener{ tcpLn: tcpLn, tlsCfg: tlsCfg, funnelCh: funnelCh, port: port, ipn: ipn, netstackCh: make(chan net.Conn, 8), errCh: make(chan error, 1), done: make(chan struct{}), } go l.drainNetstack() return l } func (l *combinedTLSListener) drainNetstack() { for { conn, err := l.tcpLn.Accept() if err != nil { select { case l.errCh <- err: default: } return } tlsConn := tls.Server(conn, l.tlsCfg) select { case l.netstackCh <- tlsConn: case <-l.done: conn.Close() return } } } func (l *combinedTLSListener) Accept() (net.Conn, error) { select { case conn := <-l.funnelCh: return conn, nil case conn := <-l.netstackCh: return conn, nil case err := <-l.errCh: return nil, err case <-l.done: return nil, net.ErrClosed } } func (l *combinedTLSListener) Close() error { l.closeOnce.Do(func() { close(l.done) l.tcpLn.Close() l.ipn.funnelMu.Lock() delete(l.ipn.funnelPorts, l.port) l.ipn.funnelMu.Unlock() }) return nil } func (l *combinedTLSListener) Addr() net.Addr { return l.tcpLn.Addr() } func (i *jsIPN) setFunnel(hostname string, port uint16, enabled bool) js.Value { return makePromise(func() (any, error) { hp := ipn.HostPort(fmt.Sprintf("%s:%d", hostname, port)) var cfg *ipn.ServeConfig if enabled { cfg = &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{hp: true}, } } else { cfg = &ipn.ServeConfig{} } return nil, i.lb.SetServeConfig(cfg, "") }) } 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{ "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() }), } } // wrapTCPListener exposes a net.Listener to JavaScript as an object with // accept/close/addr methods plus a Symbol.asyncIterator implementation, so // callers can write `for await (const conn of listener)`. func wrapTCPListener(ln net.Listener) js.Value { obj := js.Global().Get("Object").New() obj.Set("accept", js.FuncOf(func(this js.Value, args []js.Value) any { return makePromise(func() (any, error) { conn, err := ln.Accept() if err != nil { return nil, err } return wrapConn(conn), nil }) })) obj.Set("close", js.FuncOf(func(this js.Value, args []js.Value) any { return ln.Close() != nil })) obj.Set("addr", js.FuncOf(func(this js.Value, args []js.Value) any { return ln.Addr().String() })) asyncIterSym := js.Global().Get("Symbol").Get("asyncIterator") iterFactory := js.FuncOf(func(this js.Value, args []js.Value) any { iter := js.Global().Get("Object").New() iter.Set("next", js.FuncOf(func(this js.Value, args []js.Value) any { return makePromise(func() (any, error) { conn, err := ln.Accept() if err != nil { if errors.Is(err, net.ErrClosed) { return map[string]any{ "value": js.Undefined(), "done": true, }, nil } return nil, err } return map[string]any{ "value": wrapConn(conn), "done": false, }, nil }) })) return iter }) js.Global().Get("Reflect").Call("set", obj, asyncIterSym, iterFactory) return obj } // 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 } func (w termWriter) Write(p []byte) (n int, err error) { r := bytes.Replace(p, []byte("\n"), []byte("\n\r"), -1) w.f.Invoke(string(r)) return len(p), nil } // jsIncomingFile is the JSON representation of an in-progress inbound file // transfer sent to the notifyIncomingFiles callback. type jsIncomingFile struct { Name string `json:"name"` Started int64 `json:"started"` // Unix milliseconds; use new Date(started) in JS DeclaredSize int64 `json:"declaredSize"` // -1 if unknown Received int64 `json:"received"` // bytes received so far Done bool `json:"done"` // true once the file has been fully received } // jsOutgoingFile is the JSON representation of an outgoing file transfer // sent to the notifyOutgoingFiles callback. type jsOutgoingFile struct { ID string `json:"id"` PeerID string `json:"peerID"` Name string `json:"name"` Started int64 `json:"started"` // Unix milliseconds DeclaredSize int64 `json:"declaredSize"` // -1 if unknown Sent int64 `json:"sent"` // bytes sent so far Finished bool `json:"finished"` Succeeded bool `json:"succeeded"` // only meaningful when finished } type jsNetMap struct { Self jsNetMapSelfNode `json:"self"` Peers []jsNetMapPeerNode `json:"peers"` LockedOut bool `json:"lockedOut"` } type jsNetMapNode struct { Name string `json:"name"` Addresses []string `json:"addresses"` MachineKey string `json:"machineKey"` NodeKey string `json:"nodeKey"` PeerAPIURL string `json:"peerAPIURL,omitempty"` } type jsNetMapSelfNode struct { jsNetMapNode MachineStatus string `json:"machineStatus"` } type jsNetMapPeerNode struct { jsNetMapNode Online *bool `json:"online,omitempty"` TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"` ExitNodeOption bool `json:"exitNodeOption"` StableNodeID string `json:"stableNodeID"` } type jsStateStore struct { jsStateStorage js.Value } func (s *jsStateStore) ReadState(id ipn.StateKey) ([]byte, error) { jsValue := s.jsStateStorage.Call("getState", string(id)) if jsValue.String() == "" { return nil, ipn.ErrStateNotExist } return hex.DecodeString(jsValue.String()) } func (s *jsStateStore) WriteState(id ipn.StateKey, bs []byte) error { s.jsStateStorage.Call("setState", string(id), hex.EncodeToString(bs)) return nil } func mapSlice[T any, M any](a []T, f func(T) M) []M { n := make([]M, len(a)) for i, e := range a { n[i] = f(e) } return n } func mapSliceView[T any, M any](a views.Slice[T], f func(T) M) []M { n := make([]M, a.Len()) for i, v := range a.All() { n[i] = f(v) } return n } func filterSlice[T any](a []T, f func(T) bool) []T { n := make([]T, 0, len(a)) for _, e := range a { if f(e) { n = append(n, e) } } return n } func generateHostname() string { tails := words.Tails() scales := words.Scales() if rand.IntN(2) == 0 { // JavaScript tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "j") }) scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "s") }) } else { // WebAssembly tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "w") }) scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "a") }) } tail := tails[rand.IntN(len(tails))] scale := scales[rand.IntN(len(scales))] return fmt.Sprintf("%s-%s", tail, scale) } // makePromise handles the boilerplate of wrapping goroutines with JS promises. // f is run on a goroutine and its return value is used to resolve the promise // (or reject it if an error is returned). func makePromise(f func() (any, error)) js.Value { handler := js.FuncOf(func(this js.Value, args []js.Value) any { resolve := args[0] reject := args[1] go func() { if res, err := f(); err == nil { resolve.Invoke(res) } else { reject.Invoke(err.Error()) } }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } const logPolicyStateKey = "log-policy" func getOrCreateLogPolicyConfig(state ipn.StateStore) *logpolicy.Config { if configBytes, err := state.ReadState(logPolicyStateKey); err == nil { if config, err := logpolicy.ConfigFromBytes(configBytes); err == nil { return config } else { log.Printf("Could not parse log policy config: %v", err) } } else if err != ipn.ErrStateNotExist { log.Printf("Could not get log policy config from state store: %v", err) } config := logpolicy.NewConfig(logtail.CollectionNode) if err := state.WriteState(logPolicyStateKey, config.ToBytes()); err != nil { log.Printf("Could not save log policy config to state store: %v", err) } return config } // noCORSTransport wraps a RoundTripper and forces the no-cors mode on requests, // so that we can use it with non-CORS-aware servers. type noCORSTransport struct { http.RoundTripper } func (t *noCORSTransport) RoundTrip(req *http.Request) (*http.Response, error) { req.Header.Set("js.fetch:mode", "no-cors") resp, err := t.RoundTripper.RoundTrip(req) if err == nil { // In no-cors mode no response properties are returned. Populate just // the status so that callers do not think this was an error. resp.StatusCode = http.StatusOK resp.Status = http.StatusText(http.StatusOK) } return resp, err }