52cae45f81
The constant tailcfg.PingICMP is "ICMP" not "icmp"; the error message was listing the wrong string, causing user confusion about valid values. 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
|
|
}
|