Files
tailscale/cmd/tsconnect/wasm/wasm_js.go
T
codinget 7fd2507611 fix(wasm): validate ping type early; fallback DNS resolver for exit node
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 <noreply@anthropic.com>
2026-05-10 15:20:40 +00:00

1706 lines
47 KiB
Go

// 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 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{
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 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(),
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
}