jsIPN.localAPI serves localapi.Handler in-process through an httptest.ResponseRecorder, so nothing ever dialled the safesocket listener that run() opened. The other in-tree callers of safesocket.ConnectContext do not apply either: driveimpl's FileSystemForRemote is replaced by jsFileSystemForRemote in this build, and logpolicy's fallback is behind version.IsWindowsGUI. Remove the listener, and with it ipnserver, whose only remaining use was serving that listener. ipnserver.New builds a struct and SetLocalBackend stores a pointer, so dropping both leaves LocalBackend untouched. Two behaviours go with it: srv.Run's deferred lb.Shutdown, which made shutdown call lb.Shutdown twice, and its localapi.Shutdown bus subscription, which nothing in this build emits. safesocket's generated per-listener name existed so several IPNs could share one runtime. Each runtime has its own Go heap and its own memconn registry, so the fixed name never conflicted between runtimes, and there is now at most one IPN in each. Restoring the fixed name returns safesocket_js.go to its upstream contents. Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
1625 lines
47 KiB
Go
1625 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(), and logout().
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math/rand/v2"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall/js"
|
|
"time"
|
|
|
|
"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/feature/condregister"
|
|
"tailscale.com/ipn"
|
|
"tailscale.com/ipn/ipnauth"
|
|
"tailscale.com/ipn/ipnlocal"
|
|
"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/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
|
|
|
|
// initCallbackEnv names the JS global holding the callback that this runtime
|
|
// hands its bridge to. The loader generates a name, installs the callback under
|
|
// it, and passes the name in through go.env before starting the runtime.
|
|
//
|
|
// Publishing the bridge through a caller-supplied callback rather than a fixed
|
|
// global means the loader never has to guess when the Go scheduler has run far
|
|
// enough to expose it, and two runtimes in one JS realm cannot collide on the
|
|
// name.
|
|
const initCallbackEnv = "TSCONNECT_INIT_CALLBACK"
|
|
|
|
func main() {
|
|
name := os.Getenv(initCallbackEnv)
|
|
if name == "" {
|
|
log.Fatalf("%s is not set; this module must be loaded by @webnet/tsconnect", initCallbackEnv)
|
|
}
|
|
callback := js.Global().Get(name)
|
|
if callback.Type() != js.TypeFunction {
|
|
log.Fatalf("globalThis[%q] is not a function", name)
|
|
}
|
|
|
|
shutdownCh := make(chan struct{})
|
|
var claimed atomic.Bool
|
|
callback.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
return makePromise(func() (any, error) {
|
|
if len(args) != 1 {
|
|
return nil, errors.New("newIPN takes exactly one argument")
|
|
}
|
|
// One IPN per runtime: shutdown exits the shared Go runtime, so a
|
|
// second IPN here would be torn down by the first one's shutdown.
|
|
if !claimed.CompareAndSwap(false, true) {
|
|
return nil, errors.New("this WASM runtime already has an IPN; start another runtime instead")
|
|
}
|
|
return newIPN(args[0], shutdownCh)
|
|
})
|
|
}))
|
|
|
|
// Block until shutdown() is called on the IPN, then let main return so the
|
|
// Go runtime (and all its goroutines) can be collected by the JS engine.
|
|
<-shutdownCh
|
|
}
|
|
|
|
func newIPN(jsConfig js.Value, shutdownCh chan struct{}) (map[string]any, error) {
|
|
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 {
|
|
return nil, fmt.Errorf("wgengine.NewUserspaceEngine: %w", 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 {
|
|
return nil, fmt.Errorf("netstack.Create: %w", 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) {
|
|
// Note: don't just return ns.DialContextTCP or we'll return
|
|
// *gonet.TCPConn(nil) instead of a nil interface which trips up
|
|
// callers.
|
|
tcpConn, err := ns.DialContextTCP(ctx, dst)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return tcpConn, nil
|
|
}
|
|
dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
|
|
// Note: don't just return ns.DialContextUDP or we'll return
|
|
// *gonet.UDPConn(nil) instead of a nil interface which trips up
|
|
// callers.
|
|
udpConn, err := ns.DialContextUDP(ctx, dst)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return udpConn, nil
|
|
}
|
|
sys.NetstackRouter.Set(true)
|
|
sys.Tun.Get().Start()
|
|
|
|
logid := lpc.PublicID
|
|
|
|
// initDriveForRemote must be called before NewLocalBackend (SubSystem is set-once).
|
|
driveFS := initDriveForRemote(sys)
|
|
|
|
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ipnlocal.NewLocalBackend: %w", err)
|
|
}
|
|
if err := ns.Start(lb); err != nil {
|
|
return nil, fmt.Errorf("starting netstack: %w", err)
|
|
}
|
|
wireTaildropFileOps(lb, jsConfig.Get("fileOps"))
|
|
|
|
jsIPN := &jsIPN{
|
|
dialer: dialer,
|
|
lb: lb,
|
|
ns: ns,
|
|
controlURL: controlURL,
|
|
authKey: authKey,
|
|
hostname: hostname,
|
|
logID: logid,
|
|
funnelPorts: make(map[uint16]*funnelListenerEntry),
|
|
shutdownCh: shutdownCh,
|
|
}
|
|
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
|
|
|
|
m := 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
|
|
}),
|
|
"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())
|
|
}),
|
|
"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) != 4 {
|
|
log.Printf("Usage: sendFile(stableNodeID, filename, stream, declaredSize)")
|
|
return nil
|
|
}
|
|
return jsIPN.sendFile(args[0].String(), args[1].String(), args[2], args[3].Int())
|
|
}),
|
|
"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()
|
|
}),
|
|
"shutdown": js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
return jsIPN.shutdown()
|
|
}),
|
|
"setServices": js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
if len(args) != 1 {
|
|
log.Printf("Usage: setServices(services)")
|
|
return nil
|
|
}
|
|
return jsIPN.setServices(args[0])
|
|
}),
|
|
"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)
|
|
}),
|
|
}
|
|
wireDriveJS(jsIPN, driveFS, m)
|
|
return m, nil
|
|
}
|
|
|
|
type jsIPN struct {
|
|
dialer *tsdial.Dialer
|
|
lb *ipnlocal.LocalBackend
|
|
ns *netstack.Impl
|
|
controlURL string
|
|
authKey string
|
|
hostname string
|
|
logID logid.PublicID
|
|
|
|
funnelMu sync.Mutex
|
|
funnelPorts map[uint16]*funnelListenerEntry
|
|
|
|
shutdownCh chan struct{} // closed by shutdown() to unblock main()
|
|
shutdownOnce sync.Once
|
|
}
|
|
|
|
// 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,
|
|
Services: userServicesFromView(nm.SelfNode.Hostinfo().Services()),
|
|
},
|
|
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 := buildPeerAPIURL(p, selfHave4, selfHave6)
|
|
|
|
return jsNetMapPeerNode{
|
|
jsNetMapNode: jsNetMapNode{
|
|
Name: name,
|
|
Addresses: addrs,
|
|
MachineKey: p.Machine().String(),
|
|
NodeKey: p.Key().String(),
|
|
PeerAPIURL: peerURL,
|
|
Services: userServicesFromView(p.Hostinfo().Services()),
|
|
},
|
|
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)
|
|
}
|
|
}()
|
|
|
|
}
|
|
|
|
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) shutdown() js.Value {
|
|
return makePromise(func() (any, error) {
|
|
i.shutdownOnce.Do(func() {
|
|
if i.lb != nil {
|
|
i.lb.Shutdown()
|
|
}
|
|
close(i.shutdownCh)
|
|
})
|
|
return nil, 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) 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"
|
|
}
|
|
// netstack.ListenTCP requires a full host:port; normalise the
|
|
// standard net.Listen form ":port" that omits the host.
|
|
if strings.HasPrefix(addr, ":") {
|
|
addr = "0.0.0.0" + addr
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
|
|
// tlsClientConfigFromJS builds a client tls.Config from optional JS options
|
|
// (serverName, insecureSkipVerify, caCerts). defaultServerName may be empty
|
|
// (STARTTLS upgrade case), in which case serverName must be provided unless
|
|
// insecureSkipVerify is set.
|
|
//
|
|
// 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.
|
|
func tlsClientConfigFromJS(defaultServerName string, opts js.Value) (*tls.Config, error) {
|
|
cfg := &tls.Config{
|
|
ServerName: defaultServerName,
|
|
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
|
|
}
|
|
}
|
|
if cfg.ServerName == "" && !cfg.InsecureSkipVerify {
|
|
return nil, fmt.Errorf("serverName is required unless insecureSkipVerify is set")
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
cfg, err := tlsClientConfigFromJS(host, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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.PingTSMP, tailcfg.PingICMP, tailcfg.PingPeerAPI:
|
|
// valid
|
|
default:
|
|
return nil, fmt.Errorf("ping: unknown type %q, must be one of: 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) setServices(jsServices js.Value) js.Value {
|
|
return makePromise(func() (any, error) {
|
|
n := jsServices.Length()
|
|
svcs := make([]tailcfg.Service, 0, n)
|
|
for idx := range n {
|
|
s := jsServices.Index(idx)
|
|
proto := tailcfg.ServiceProto(s.Get("proto").String())
|
|
port := uint16(s.Get("port").Int())
|
|
var desc string
|
|
if d := s.Get("description"); d.Type() == js.TypeString {
|
|
desc = d.String()
|
|
}
|
|
svcs = append(svcs, tailcfg.Service{Proto: proto, Port: port, Description: desc})
|
|
}
|
|
i.lb.SetExplicitServices(svcs)
|
|
return nil, nil
|
|
})
|
|
}
|
|
|
|
// userServicesFromView converts a hostinfo services slice to jsService entries,
|
|
// filtering out internal peerapi protocol entries (already reflected in peerAPIURL).
|
|
func userServicesFromView(svcs views.Slice[tailcfg.Service]) []jsService {
|
|
out := make([]jsService, 0, svcs.Len())
|
|
for _, s := range svcs.All() {
|
|
switch s.Proto {
|
|
case tailcfg.PeerAPI4, tailcfg.PeerAPI6, tailcfg.PeerAPIDNS:
|
|
continue
|
|
}
|
|
out = append(out, jsService{Proto: string(s.Proto), Port: s.Port, Description: s.Description})
|
|
}
|
|
return out
|
|
}
|
|
|
|
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()
|
|
}),
|
|
// upgradeTLS wraps the conn in TLS in place (STARTTLS-style) and
|
|
// returns a new wrapped conn sharing the same underlying net.Conn;
|
|
// the old handle must not be used afterward. On any failure —
|
|
// configuration or handshake — the underlying conn is closed, so
|
|
// callers can treat every rejection as fatal to the connection.
|
|
// With isServer, certPem and keyPem are required; otherwise client
|
|
// options as in dialTLS apply.
|
|
"upgradeTLS": js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
opts := js.Undefined()
|
|
if len(args) > 0 {
|
|
opts = args[0]
|
|
}
|
|
return makePromise(func() (any, error) {
|
|
var tlsConn *tls.Conn
|
|
hasOpts := !opts.IsUndefined() && !opts.IsNull()
|
|
if hasOpts && opts.Get("isServer").Type() == js.TypeBoolean && opts.Get("isServer").Bool() {
|
|
certPem := opts.Get("certPem")
|
|
keyPem := opts.Get("keyPem")
|
|
if certPem.Type() != js.TypeString || keyPem.Type() != js.TypeString {
|
|
conn.Close()
|
|
return nil, fmt.Errorf("upgradeTLS: certPem and keyPem are required when isServer is set")
|
|
}
|
|
cert, err := tls.X509KeyPair([]byte(certPem.String()), []byte(keyPem.String()))
|
|
if err != nil {
|
|
conn.Close()
|
|
return nil, fmt.Errorf("upgradeTLS: parsing cert/key: %w", err)
|
|
}
|
|
tlsConn = tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}})
|
|
} else {
|
|
cfg, err := tlsClientConfigFromJS("", opts)
|
|
if err != nil {
|
|
conn.Close()
|
|
return nil, err
|
|
}
|
|
tlsConn = tls.Client(conn, cfg)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
|
conn.Close()
|
|
return nil, err
|
|
}
|
|
return wrapConn(tlsConn), nil
|
|
})
|
|
}),
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 jsService struct {
|
|
Proto string `json:"proto"`
|
|
Port uint16 `json:"port"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
type jsNetMapNode struct {
|
|
Name string `json:"name"`
|
|
Addresses []string `json:"addresses"`
|
|
MachineKey string `json:"machineKey"`
|
|
NodeKey string `json:"nodeKey"`
|
|
PeerAPIURL string `json:"peerAPIURL,omitempty"`
|
|
Services []jsService `json:"services"`
|
|
}
|
|
|
|
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
|
|
}
|