tailscale/cmd/tsconnect/wasm/wasm_js.go

468 lines
11 KiB
Go
Raw Normal View History

// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// 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"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"math/rand"
"net"
"net/netip"
"strings"
"syscall/js"
"time"
"golang.org/x/crypto/ssh"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/netns"
"tailscale.com/net/tsdial"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/wgengine"
"tailscale.com/wgengine/netstack"
"tailscale.com/words"
)
func main() {
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
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 logf logger.Logf = log.Printf
dialer := new(tsdial.Dialer)
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Dialer: dialer,
})
if err != nil {
log.Fatal(err)
}
tunDev, magicConn, dnsManager, ok := eng.(wgengine.InternalsGetter).GetInternals()
if !ok {
log.Fatalf("%T is not a wgengine.InternalsGetter", eng)
}
ns, err := netstack.Create(logf, tunDev, eng, magicConn, dialer, dnsManager)
if err != nil {
log.Fatalf("netstack.Create: %v", err)
}
ns.ProcessLocalIPs = true
ns.ProcessSubnets = true
if err := ns.Start(); err != nil {
log.Fatalf("failed to start netstack: %v", err)
}
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)
}
jsStateStorage := jsConfig.Get("stateStorage")
var store ipn.StateStore
if jsStateStorage.IsUndefined() {
store = new(mem.Store)
} else {
store = &jsStateStore{jsStateStorage}
}
srv, err := ipnserver.New(log.Printf, "some-logid", store, eng, dialer, nil, ipnserver.Options{
SurviveDisconnects: true,
LoginFlags: controlclient.LoginEphemeral,
})
if err != nil {
log.Fatalf("ipnserver.New: %v", err)
}
lb := srv.LocalBackend()
jsIPN := &jsIPN{
dialer: dialer,
srv: srv,
lb: lb,
}
return map[string]any{
"run": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
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) interface{} {
if len(args) != 0 {
log.Printf("Usage: login()")
return nil
}
jsIPN.login()
return nil
}),
"logout": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) != 0 {
log.Printf("Usage: logout()")
return nil
}
jsIPN.logout()
return nil
}),
"ssh": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) != 3 {
log.Printf("Usage: ssh(hostname, userName, termConfig)")
return nil
}
return jsIPN.ssh(
args[0].String(),
args[1].String(),
args[2])
}),
}
}
type jsIPN struct {
dialer *tsdial.Dialer
srv *ipnserver.Server
lb *ipnlocal.LocalBackend
}
func (i *jsIPN) run(jsCallbacks js.Value) {
notifyState := func(state ipn.State) {
jsCallbacks.Call("notifyState", int(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 && i.lb.State() == ipn.Running {
jsNetMap := jsNetMap{
Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{
Name: nm.Name,
Addresses: mapSlice(nm.Addresses, func(a netip.Prefix) string { return a.Addr().String() }),
NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(),
},
MachineStatus: int(nm.MachineStatus),
},
Peers: mapSlice(nm.Peers, func(p *tailcfg.Node) jsNetMapPeerNode {
name := p.Name
if name == "" {
// In practice this should only happen for Hello.
name = p.Hostinfo.Hostname()
}
return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{
Name: name,
Addresses: mapSlice(p.Addresses, func(a netip.Prefix) string { return a.Addr().String() }),
MachineKey: p.Machine.String(),
NodeKey: p.Key.String(),
},
Online: p.Online,
TailscaleSSHEnabled: p.Hostinfo.TailscaleSSHEnabled(),
}
}),
}
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.BrowseToURL != nil {
jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL)
}
})
go func() {
err := i.lb.Start(ipn.Options{
StateKey: "wasm",
UpdatePrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
RouteAll: false,
AllowSingleHosts: true,
WantRunning: true,
Hostname: generateHostname(),
},
})
if err != nil {
log.Printf("Start error: %v", err)
}
}()
go func() {
ln, _, err := safesocket.Listen("", 0)
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()
}
func (i *jsIPN) logout() {
if i.lb.State() == ipn.NoState {
log.Printf("Backend not running")
}
go i.lb.Logout()
}
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) interface{} {
return jsSSHSession.Close() != nil
}),
"resize": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
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
}
func (s *jsSSHSession) Run() {
writeFn := s.termConfig.Get("writeFn")
setReadFn := s.termConfig.Get("setReadFn")
rows := s.termConfig.Get("rows").Int()
cols := s.termConfig.Get("cols").Int()
onDone := s.termConfig.Get("onDone")
defer onDone.Invoke()
write := func(s string) {
writeFn.Invoke(s)
}
writeError := func(label string, err error) {
write(fmt.Sprintf("%s Error: %v\r\n", label, err))
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
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: ssh.InsecureIgnoreHostKey(),
User: s.username,
}
sshConn, _, _, err := ssh.NewClientConn(c, s.host, config)
if err != nil {
writeError("SSH Connection", err)
return
}
defer sshConn.Close()
write("SSH Connected\r\n")
sshClient := ssh.NewClient(sshConn, nil, nil)
defer sshClient.Close()
session, err := sshClient.NewSession()
if err != nil {
writeError("SSH Session", err)
return
}
s.session = session
write("Session Established\r\n")
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) interface{} {
input := args[0].String()
_, err := stdin.Write([]byte(input))
if err != nil {
writeError("Write Input", err)
}
return nil
}))
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
}
err = session.Wait()
if err != nil {
writeError("Wait", err)
return
}
}
func (s *jsSSHSession) Close() error {
return s.session.Close()
}
func (s *jsSSHSession) Resize(rows, cols int) error {
return s.session.WindowChange(rows, cols)
}
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
}
type jsNetMap struct {
Self jsNetMapSelfNode `json:"self"`
Peers []jsNetMapPeerNode `json:"peers"`
}
type jsNetMapNode struct {
Name string `json:"name"`
Addresses []string `json:"addresses"`
MachineKey string `json:"machineKey"`
NodeKey string `json:"nodeKey"`
}
type jsNetMapSelfNode struct {
jsNetMapNode
MachineStatus int `json:"machineStatus"`
}
type jsNetMapPeerNode struct {
jsNetMapNode
Online *bool `json:"online,omitempty"`
TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"`
}
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 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.Int()%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)
}