7fd2507611
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>
1706 lines
47 KiB
Go
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
|
|
}
|