From decc3ee30d78a68575a74ffced766c5bead8ea08 Mon Sep 17 00:00:00 2001 From: Mihai Parparita Date: Tue, 7 Jun 2022 14:24:22 -0700 Subject: [PATCH] ssh/browser: implement browser client for Tailscale SSH Runs a Tailscale client in the browser (via a WebAssembly build of the wasm package) and allows SSH access to machines. The wasm package exports a newIPN function, which returns a simple JS object with methods like start(), login(), logout() and ssh(). The golang.org/x/crypto/ssh package is used for the SSH client. Terminal emulation and QR code renedring is done via NPM packages (xterm and qrcode respectively), thus we also need a JS toolchain that can install and bundle them. Yarn is used for installation, and esbuild handles loading them and bundling for production serving. Updates #3157 Signed-off-by: Mihai Parparita --- go.mod | 1 + go.sum | 11 + ssh/browser/.gitignore | 4 + ssh/browser/browser.go | 213 ++++++++++++++++ ssh/browser/dist/placeholder | 2 + ssh/browser/index.html | 16 ++ ssh/browser/package.json | 12 + ssh/browser/src/index.css | 91 +++++++ ssh/browser/src/index.js | 26 ++ ssh/browser/src/js-state-store.js | 16 ++ ssh/browser/src/login.js | 71 ++++++ ssh/browser/src/notifier.js | 75 ++++++ ssh/browser/src/ssh.js | 77 ++++++ ssh/browser/wasm/wasm_js.go | 406 ++++++++++++++++++++++++++++++ ssh/browser/yarn.lock | 205 +++++++++++++++ 15 files changed, 1226 insertions(+) create mode 100644 ssh/browser/.gitignore create mode 100644 ssh/browser/browser.go create mode 100644 ssh/browser/dist/placeholder create mode 100644 ssh/browser/index.html create mode 100644 ssh/browser/package.json create mode 100644 ssh/browser/src/index.css create mode 100644 ssh/browser/src/index.js create mode 100644 ssh/browser/src/js-state-store.js create mode 100644 ssh/browser/src/login.js create mode 100644 ssh/browser/src/notifier.js create mode 100644 ssh/browser/src/ssh.js create mode 100644 ssh/browser/wasm/wasm_js.go create mode 100644 ssh/browser/yarn.lock diff --git a/go.mod b/go.mod index 930e323eb..1d5440309 100644 --- a/go.mod +++ b/go.mod @@ -118,6 +118,7 @@ require ( github.com/emirpasic/gods v1.12.0 // indirect github.com/esimonov/ifshort v1.0.3 // indirect github.com/ettle/strcase v0.1.1 // indirect + github.com/evanw/esbuild v0.14.39 // indirect github.com/fatih/color v1.13.0 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect diff --git a/go.sum b/go.sum index 3823b1bf3..5cb745176 100644 --- a/go.sum +++ b/go.sum @@ -278,6 +278,8 @@ github.com/esimonov/ifshort v1.0.3 h1:JD6x035opqGec5fZ0TLjXeROD2p5H7oLGn8MKfy9HT github.com/esimonov/ifshort v1.0.3/go.mod h1:yZqNJUrNn20K8Q9n2CrjTKYyVEmX209Hgu+M1LBpeZE= github.com/ettle/strcase v0.1.1 h1:htFueZyVeE1XNnMEfbqp5r67qAN/4r6ya1ysq8Q+Zcw= github.com/ettle/strcase v0.1.1/go.mod h1:hzDLsPC7/lwKyBOywSHEP89nt2pDgdy+No1NBA9o9VY= +github.com/evanw/esbuild v0.14.39 h1:1TMZtCXOY4ctAbGY4QT9sjT203I/cQ16vXt2F9rLT58= +github.com/evanw/esbuild v0.14.39/go.mod h1:GG+zjdi59yh3ehDn4ZWfPcATxjPDUH53iU4ZJbp7dkY= github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72/go.mod h1:PjfxuH4FZdUyfMdtBio2lsRr1AKEaVPwelzuHuh8Lqc= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= @@ -300,6 +302,7 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= @@ -339,9 +342,11 @@ github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvSc github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/go-redis/redis v6.15.8+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= @@ -376,9 +381,11 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -833,6 +840,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= @@ -1141,6 +1150,7 @@ github.com/u-root/uio v0.0.0-20210528151154-e40b768296a7/go.mod h1:LpEX5FO/cB+WF github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= @@ -1500,6 +1510,7 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210915083310-ed5796bab164/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/ssh/browser/.gitignore b/ssh/browser/.gitignore new file mode 100644 index 000000000..138f976ac --- /dev/null +++ b/ssh/browser/.gitignore @@ -0,0 +1,4 @@ +src/wasm_exec.js +src/main.wasm +node_modules/ +dist/ diff --git a/ssh/browser/browser.go b/ssh/browser/browser.go new file mode 100644 index 000000000..8d1871d0f --- /dev/null +++ b/ssh/browser/browser.go @@ -0,0 +1,213 @@ +// 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. + +// Builds and serves the static site that is generated for the browser/Wasm +// Tailscale SSH client. Can be run in 3 modes: +// - dev: builds the site and serves it. JS and CSS changes can be picked up +// with a reload. +// - build: builds the site and writes it to dist/ +// - serve: serves the site from dist/ (embedded in the binary) +package main + +import ( + "embed" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strconv" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +var ( + dev = flag.Bool("dev", false, "Run in dev build and serve mode") + build = flag.Bool("build", false, "Run in production build mode (generating static assets)") + serve = flag.Bool("serve", false, "Run in production serve mode (serving static assets)") + addr = flag.String("addr", ":9090", "address to listen on") +) + +func main() { + flag.Parse() + + if *dev { + runDev() + } else if *build { + runBuild() + } else if *serve { + runServe() + } else { + log.Fatal("No mode specified") + } +} + +func runDev() { + buildOptions, err := commonSetup() + if err != nil { + log.Fatalf("Cannot setup: %v", err) + } + host, portStr, err := net.SplitHostPort(*addr) + if err != nil { + log.Fatalf("Cannot parse addr: %v", err) + } + port, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + log.Fatalf("Cannot parse port: %v", err) + } + result, err := esbuild.Serve(esbuild.ServeOptions{ + Port: uint16(port), + Host: host, + Servedir: "./", + }, *buildOptions) + if err != nil { + log.Fatalf("Cannot start esbuild server: %v", err) + } + log.Printf("Listening on http://%s:%d\n", result.Host, result.Port) + result.Wait() +} + +func runBuild() { + buildOptions, err := commonSetup() + if err != nil { + log.Fatalf("Cannot setup: %v", err) + } + + if err := cleanDist(); err != nil { + log.Fatalf("Cannot clean dist/: %v", err) + } + + buildOptions.Write = true + buildOptions.MinifyWhitespace = true + buildOptions.MinifyIdentifiers = true + buildOptions.MinifySyntax = true + + result := esbuild.Build(*buildOptions) + if len(result.Errors) > 0 { + log.Printf("ESBuild Error:\n") + for _, e := range result.Errors { + log.Printf("%v", e) + } + log.Fatal("Build failed") + } + if len(result.Warnings) > 0 { + log.Printf("ESBuild Warnings:\n") + for _, w := range result.Warnings { + log.Printf("%v", w) + } + } +} + +//go:embed dist/* index.html +var embeddedFS embed.FS + +func runServe() { + log.Printf("Listening on %s", *addr) + err := http.ListenAndServe(*addr, http.FileServer(http.FS(embeddedFS))) + if err != nil { + log.Fatal(err) + } +} + +// commonSetup performs setup that is common to both dev and build modes. +func commonSetup() (*esbuild.BuildOptions, error) { + // Change cwd to to where this file lives -- that's where all inputs for + // esbuild and other build steps live. + if _, filename, _, ok := runtime.Caller(0); ok { + if err := os.Chdir(path.Dir(filename)); err != nil { + return nil, fmt.Errorf("Cannot change cwd: %v", err) + } + } + if err := buildDeps(); err != nil { + return nil, fmt.Errorf("Cannot build deps: %v", err) + } + + return &esbuild.BuildOptions{ + EntryPoints: []string{"src/index.js", "src/index.css"}, + Loader: map[string]esbuild.Loader{".wasm": esbuild.LoaderFile}, + Outdir: "./dist", + Bundle: true, + Sourcemap: esbuild.SourceMapLinked, + LogLevel: esbuild.LogLevelInfo, + Define: map[string]string{"DEBUG": strconv.FormatBool(*dev)}, + Target: esbuild.ES2017, + }, nil +} + +// buildDeps builds the static assets that are needed for the server (except for +// JS/CSS bundling, which is handled by esbuild). +func buildDeps() error { + if err := copyWasmExec(); err != nil { + return fmt.Errorf("Cannot copy wasm_exec.js: %v", err) + } + if err := buildWasm(); err != nil { + return fmt.Errorf("Cannot build main.wasm: %v", err) + } + if err := installJsDeps(); err != nil { + return fmt.Errorf("Cannot install JS deps: %v", err) + } + return nil +} + +// copyWasmExec grabs the current wasm_exec.js runtime helper library from the +// Go toolchain. +func copyWasmExec() error { + log.Printf("Copying wasm_exec.js...\n") + wasmExecSrcPath := filepath.Join(runtime.GOROOT(), "misc", "wasm", "wasm_exec.js") + wasmExecDstPath := filepath.Join("src", "wasm_exec.js") + contents, err := os.ReadFile(wasmExecSrcPath) + if err != nil { + return err + } + return os.WriteFile(wasmExecDstPath, contents, 0600) +} + +// buildWasm builds the Tailscale wasm binary and places it where the JS can +// load it. +func buildWasm() error { + log.Printf("Building wasm...\n") + args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,nethttpomithttp2,omitidna,omitpemdecrypt"} + if !*dev { + // Omit long paths and debug symbols in release builds, to reduce the + // generated WASM binary size. + args = append(args, "-trimpath", "-ldflags", "-s -w") + } + args = append(args, "-o", "src/main.wasm", "./wasm") + cmd := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), args...) + cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// installJsDeps installs the JavaScript dependencies specified by package.json +func installJsDeps() error { + log.Printf("Installing JS deps...\n") + return exec.Command("yarn").Run() +} + +// cleanDist removes files from the dist build directory, except the placeholder +// one that we keep to make sure Git still creates the directory. +func cleanDist() error { + log.Printf("Cleaning dist/...\n") + files, err := os.ReadDir("dist") + if err != nil { + return err + } + + for _, file := range files { + if file.Name() != "placeholder" { + if err := os.Remove(filepath.Join("dist", file.Name())); err != nil { + return err + } + } + } + return nil +} diff --git a/ssh/browser/dist/placeholder b/ssh/browser/dist/placeholder new file mode 100644 index 000000000..6ab149257 --- /dev/null +++ b/ssh/browser/dist/placeholder @@ -0,0 +1,2 @@ +This is here to make sure the dist directory exists for the go:embed command +in browser.go. diff --git a/ssh/browser/index.html b/ssh/browser/index.html new file mode 100644 index 000000000..0cdc01bf6 --- /dev/null +++ b/ssh/browser/index.html @@ -0,0 +1,16 @@ + + + + + + + + + +
+ + + diff --git a/ssh/browser/package.json b/ssh/browser/package.json new file mode 100644 index 000000000..15151ad6c --- /dev/null +++ b/ssh/browser/package.json @@ -0,0 +1,12 @@ +{ + "name": "@tailscale/ssh", + "version": "0.0.1", + "devDependencies": { + "qrcode": "^1.5.0", + "xterm": "^4.18.0" + }, + "prettier": { + "semi": false, + "printWidth": 80 + } +} diff --git a/ssh/browser/src/index.css b/ssh/browser/src/index.css new file mode 100644 index 000000000..83cd9c6fe --- /dev/null +++ b/ssh/browser/src/index.css @@ -0,0 +1,91 @@ +/* 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. */ + +@import "xterm/css/xterm.css"; + +html { + background: #fff; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} + +body { + margin: 0; +} + +button { + font-family: inherit; + border: solid 1px #ccc; + background: #fff; + color: #000; + padding: 4px 8px; + border-radius: 4px; +} + +#header { + background: #f7f5f4; + border-bottom: 1px solid #eeebea; + padding: 12px; + display: flex; + align-items: center; +} + +#header h1 { + margin: 0; + flex-grow: 1; +} + +#header #state { + padding: 0 8px; + color: #444342; +} + +#peers { + box-sizing: border-box; + width: 100%; + padding: 12px; +} + +.login { + text-align: center; +} + +.logout { + font-weight: bold; +} + +.peer { + display: flex; + justify-content: space-between; + padding: 2px; +} + +.peer:hover { + background: #eee; +} + +.peer .name { + font-family: monospace; +} + +.peer .ssh { + background-color: #cbf4c9; +} + +.term-container { + padding: 12px; +} + +.xterm-viewport.xterm-viewport { + scrollbar-width: thin; +} +.xterm-viewport::-webkit-scrollbar { + width: 10px; +} +.xterm-viewport::-webkit-scrollbar-track { + opacity: 0; +} +.xterm-viewport::-webkit-scrollbar-thumb { + min-height: 20px; + background-color: #ffffff20; +} diff --git a/ssh/browser/src/index.js b/ssh/browser/src/index.js new file mode 100644 index 000000000..f5095f873 --- /dev/null +++ b/ssh/browser/src/index.js @@ -0,0 +1,26 @@ +// 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. + +import "./wasm_exec" +import wasmUrl from "./main.wasm" +import { notifyState, notifyNetMap, notifyBrowseToURL } from "./notifier" +import { sessionStateStorage } from "./js-state-store" + +const go = new window.Go() +WebAssembly.instantiateStreaming( + fetch(`./dist/${wasmUrl}`), + go.importObject +).then((result) => { + go.run(result.instance) + const ipn = newIPN({ + // Persist IPN state in sessionStorage in development, so that we don't need + // to re-authorize every time we reload the page. + stateStorage: DEBUG ? sessionStateStorage : undefined, + }) + ipn.run({ + notifyState: notifyState.bind(null, ipn), + notifyNetMap: notifyNetMap.bind(null, ipn), + notifyBrowseToURL: notifyBrowseToURL.bind(null, ipn), + }) +}) diff --git a/ssh/browser/src/js-state-store.js b/ssh/browser/src/js-state-store.js new file mode 100644 index 000000000..c0b509d2b --- /dev/null +++ b/ssh/browser/src/js-state-store.js @@ -0,0 +1,16 @@ +// 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. + +/** + * @fileoverview Callbacks used by jsStateStore to persist IPN state. + */ + +export const sessionStateStorage = { + setState(id, value) { + window.sessionStorage[`ipn-state-${id}`] = value + }, + getState(id) { + return window.sessionStorage[`ipn-state-${id}`] || "" + }, +} diff --git a/ssh/browser/src/login.js b/ssh/browser/src/login.js new file mode 100644 index 000000000..fe6901914 --- /dev/null +++ b/ssh/browser/src/login.js @@ -0,0 +1,71 @@ +// 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. + +import QRCode from "qrcode" + +export async function showLoginURL(url) { + if (loginNode) { + loginNode.remove() + } + loginNode = document.createElement("div") + loginNode.className = "login" + const linkNode = document.createElement("a") + linkNode.href = url + linkNode.target = "_blank" + loginNode.appendChild(linkNode) + + try { + const dataURL = await QRCode.toDataURL(url, { width: 512 }) + const imageNode = document.createElement("img") + imageNode.src = dataURL + imageNode.width = 256 + imageNode.height = 256 + imageNode.border = "0" + linkNode.appendChild(imageNode) + } catch (err) { + console.error("Could not generate QR code:", err) + } + + linkNode.appendChild(document.createElement("br")) + linkNode.appendChild(document.createTextNode(url)) + + document.body.appendChild(loginNode) +} + +export function hideLoginURL() { + if (!loginNode) { + return + } + loginNode.remove() + loginNode = undefined +} + +let loginNode + +export function showLogoutButton(ipn) { + if (logoutButtonNode) { + logoutButtonNode.remove() + } + logoutButtonNode = document.createElement("button") + logoutButtonNode.className = "logout" + logoutButtonNode.textContent = "Logout" + logoutButtonNode.addEventListener( + "click", + () => { + ipn.logout() + }, + { once: true } + ) + document.getElementById("header").appendChild(logoutButtonNode) +} + +export function hideLogoutButton() { + if (!logoutButtonNode) { + return + } + logoutButtonNode.remove() + logoutButtonNode = undefined +} + +let logoutButtonNode diff --git a/ssh/browser/src/notifier.js b/ssh/browser/src/notifier.js new file mode 100644 index 000000000..71317f01e --- /dev/null +++ b/ssh/browser/src/notifier.js @@ -0,0 +1,75 @@ +// 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. + +import { + showLoginURL, + hideLoginURL, + showLogoutButton, + hideLogoutButton, +} from "./login" +import { showSSHPeers, hideSSHPeers } from "./ssh" + +/** + * @fileoverview Notification callback functions (bridged from ipn.Notify) + */ + +/** Mirrors values from ipn/backend.go */ +const State = { + NoState: 0, + InUseOtherUser: 1, + NeedsLogin: 2, + NeedsMachineAuth: 3, + Stopped: 4, + Starting: 5, + Running: 6, +} + +export function notifyState(ipn, state) { + let stateLabel + switch (state) { + case State.NoState: + stateLabel = "Initializing…" + break + case State.InUseOtherUser: + stateLabel = "In-use by another user" + break + case State.NeedsLogin: + stateLabel = "Needs Login" + hideLogoutButton() + hideSSHPeers() + ipn.login() + break + case State.NeedsMachineAuth: + stateLabel = "Needs authorization" + break + case State.Stopped: + stateLabel = "Stopped" + hideLogoutButton() + hideSSHPeers() + break + case State.Starting: + stateLabel = "Starting…" + break + case State.Running: + stateLabel = "Running" + hideLoginURL() + showLogoutButton(ipn) + break + } + const stateNode = document.getElementById("state") + stateNode.textContent = stateLabel ?? "" +} + +export function notifyNetMap(ipn, netMapStr) { + const netMap = JSON.parse(netMapStr) + if (DEBUG) { + console.log("Received net map: " + JSON.stringify(netMap, null, 2)) + } + + showSSHPeers(netMap.peers, ipn) +} + +export function notifyBrowseToURL(ipn, url) { + showLoginURL(url) +} diff --git a/ssh/browser/src/ssh.js b/ssh/browser/src/ssh.js new file mode 100644 index 000000000..7604f4a17 --- /dev/null +++ b/ssh/browser/src/ssh.js @@ -0,0 +1,77 @@ +// 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. + +import { Terminal } from "xterm" + +export function showSSHPeers(peers, ipn) { + const peersNode = document.getElementById("peers") + peersNode.innerHTML = "" + + const sshPeers = peers.filter((p) => p.tailscaleSSHEnabled) + if (!sshPeers.length) { + peersNode.textContent = "No machines have Tailscale SSH installed." + return + } + + for (const peer of sshPeers) { + const peerNode = document.createElement("div") + peerNode.className = "peer" + const nameNode = document.createElement("div") + nameNode.className = "name" + nameNode.textContent = peer.name + peerNode.appendChild(nameNode) + + const sshButtonNode = document.createElement("button") + sshButtonNode.className = "ssh" + sshButtonNode.addEventListener("click", function () { + ssh(peer.name, ipn) + }) + sshButtonNode.textContent = "SSH" + peerNode.appendChild(sshButtonNode) + + peersNode.appendChild(peerNode) + } +} + +export function hideSSHPeers() { + const peersNode = document.getElementById("peers") + peersNode.innerHTML = "" +} + +function ssh(hostname, ipn) { + const termContainerNode = document.createElement("div") + termContainerNode.className = "term-container" + document.body.appendChild(termContainerNode) + + const term = new Terminal({ + cursorBlink: true, + }) + term.open(termContainerNode) + + // Cancel wheel events from scrolling the page if the terminal has scrollback + termContainerNode.addEventListener("wheel", (e) => { + if (term.buffer.active.baseY > 0) { + e.preventDefault() + } + }) + + let onDataHook + term.onData((e) => { + onDataHook?.(e) + }) + + term.focus() + + ipn.ssh( + hostname, + (input) => term.write(input), + (hook) => (onDataHook = hook), + term.rows, + term.cols, + () => { + term.dispose() + termContainerNode.remove() + } + ) +} diff --git a/ssh/browser/wasm/wasm_js.go b/ssh/browser/wasm/wasm_js.go new file mode 100644 index 000000000..2e86ed048 --- /dev/null +++ b/ssh/browser/wasm/wasm_js.go @@ -0,0 +1,406 @@ +// 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. + +// Package that generates JS bindings for Tailscale via WebAssembly. +// +// 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 +// start(callbacks), login(), logout(), and ssh(...). +package main + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "math/rand" + "net" + "strings" + "syscall/js" + "time" + + "golang.org/x/crypto/ssh" + "inet.af/netaddr" + "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 netaddr.IP) bool { + return true + } + dialer.NetstackDialTCP = func(ctx context.Context, dst netaddr.IPPort) (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(callbacks)") + 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: logout()") + 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) != 6 { + log.Printf("Usage: ssh(hostname, writeFn, readFn, row, cols, onDone)") + return nil + } + go jsIPN.ssh( + args[0].String(), + args[1], + args[2], + args[3].Int(), + args[4].Int(), + args[5]) + return nil + }), + } +} + +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) { + log.Printf("NOTIFY: %+v", n) + if n.State != nil { + notifyState(*n.State) + } + if nm := n.NetMap; nm != nil { + jsNetMap := jsNetMap{ + Self: jsNetMapSelfNode{ + jsNetMapNode: jsNetMapNode{ + Name: nm.Name, + Addresses: mapSlice(nm.Addresses, func(a netaddr.IPPrefix) string { return a.IP().String() }), + NodeKey: nm.NodeKey.String(), + MachineKey: nm.MachineKey.String(), + }, + MachineStatus: int(nm.MachineStatus), + }, + Peers: mapSlice(nm.Peers, func(p *tailcfg.Node) jsNetMapPeerNode { + return jsNetMapPeerNode{ + jsNetMapNode: jsNetMapNode{ + Name: p.Name, + Addresses: mapSlice(p.Addresses, func(a netaddr.IPPrefix) string { return a.IP().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 string, writeFn js.Value, setReadFn js.Value, rows, cols int, onDone js.Value) { + 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 := i.dialer.UserDial(ctx, "tcp", net.JoinHostPort(host, "22")) + if err != nil { + writeError("Dial", err) + return + } + defer c.Close() + + config := &ssh.ClientConfig{ + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + sshConn, _, _, err := ssh.NewClientConn(c, 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 + } + 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("Exit", err) + return + } +} + +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"` + MachineStatus int `json:"machineStatus"` + MachineKey string `json:"machineKey"` + NodeKey string `json:"nodeKey"` +} + +type jsNetMapSelfNode struct { + jsNetMapNode + MachineStatus int `json:"machineStatus"` +} + +type jsNetMapPeerNode struct { + jsNetMapNode + Online bool `json:"online"` + 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) +} diff --git a/ssh/browser/yarn.lock b/ssh/browser/yarn.lock new file mode 100644 index 000000000..8315985be --- /dev/null +++ b/ssh/browser/yarn.lock @@ -0,0 +1,205 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +camelcase@^5.0.0: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + +dijkstrajs@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257" + integrity sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +encode-utf8@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" + integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw== + +find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +get-caller-file@^2.0.1: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +pngjs@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" + integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== + +qrcode@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.0.tgz#95abb8a91fdafd86f8190f2836abbfc500c72d1b" + integrity sha512-9MgRpgVc+/+47dFvQeD6U2s0Z92EsKzcHogtum4QB+UNd025WOJSHvn/hjk9xmzj7Stj95CyUAs31mrjxliEsQ== + dependencies: + dijkstrajs "^1.0.1" + encode-utf8 "^1.0.3" + pngjs "^5.0.0" + yargs "^15.3.1" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= + +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +which-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" + integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= + +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +xterm@^4.18.0: + version "4.18.0" + resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1" + integrity sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ== + +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs@^15.3.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2"