From 6f5096fa61f36e20db84fa7c9b8806016393778f Mon Sep 17 00:00:00 2001 From: Mihai Parparita Date: Tue, 7 Jun 2022 14:24:22 -0700 Subject: [PATCH] cmd/tsconnect: initial scaffolding for Tailscale Connect browser client 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 --- cmd/tsconnect/.gitignore | 4 + cmd/tsconnect/build.go | 152 ++++++++++ cmd/tsconnect/common.go | 105 +++++++ cmd/tsconnect/dev.go | 38 +++ cmd/tsconnect/dist/placeholder | 2 + cmd/tsconnect/index.html | 16 ++ cmd/tsconnect/package.json | 12 + cmd/tsconnect/serve.go | 134 +++++++++ cmd/tsconnect/src/index.css | 91 ++++++ cmd/tsconnect/src/index.js | 26 ++ cmd/tsconnect/src/js-state-store.js | 16 ++ cmd/tsconnect/src/login.js | 71 +++++ cmd/tsconnect/src/notifier.js | 75 +++++ cmd/tsconnect/src/ssh.js | 77 ++++++ cmd/tsconnect/tsconnect.go | 60 ++++ cmd/tsconnect/wasm/wasm_js.go | 411 ++++++++++++++++++++++++++++ cmd/tsconnect/yarn.lock | 205 ++++++++++++++ go.mod | 2 + go.sum | 12 + 19 files changed, 1509 insertions(+) create mode 100644 cmd/tsconnect/.gitignore create mode 100644 cmd/tsconnect/build.go create mode 100644 cmd/tsconnect/common.go create mode 100644 cmd/tsconnect/dev.go create mode 100644 cmd/tsconnect/dist/placeholder create mode 100644 cmd/tsconnect/index.html create mode 100644 cmd/tsconnect/package.json create mode 100644 cmd/tsconnect/serve.go create mode 100644 cmd/tsconnect/src/index.css create mode 100644 cmd/tsconnect/src/index.js create mode 100644 cmd/tsconnect/src/js-state-store.js create mode 100644 cmd/tsconnect/src/login.js create mode 100644 cmd/tsconnect/src/notifier.js create mode 100644 cmd/tsconnect/src/ssh.js create mode 100644 cmd/tsconnect/tsconnect.go create mode 100644 cmd/tsconnect/wasm/wasm_js.go create mode 100644 cmd/tsconnect/yarn.lock diff --git a/cmd/tsconnect/.gitignore b/cmd/tsconnect/.gitignore new file mode 100644 index 000000000..138f976ac --- /dev/null +++ b/cmd/tsconnect/.gitignore @@ -0,0 +1,4 @@ +src/wasm_exec.js +src/main.wasm +node_modules/ +dist/ diff --git a/cmd/tsconnect/build.go b/cmd/tsconnect/build.go new file mode 100644 index 000000000..a42cef3ea --- /dev/null +++ b/cmd/tsconnect/build.go @@ -0,0 +1,152 @@ +// 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 main + +import ( + "bytes" + "compress/gzip" + "io" + "io/fs" + "io/ioutil" + "log" + "os" + "path/filepath" + + "github.com/andybalholm/brotli" + esbuild "github.com/evanw/esbuild/pkg/api" + "golang.org/x/sync/errgroup" +) + +func runBuild() { + buildOptions, err := commonSetup(prodMode) + 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 + + buildOptions.EntryNames = "[dir]/[name]-[hash]" + buildOptions.AssetNames = "[name]-[hash]" + buildOptions.Metafile = true + + log.Printf("Running esbuild...\n") + 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) + } + } + + // Preserve build metadata so we can extract hashed file names for serving. + if err := ioutil.WriteFile("./dist/esbuild-metadata.json", []byte(result.Metafile), 0666); err != nil { + log.Fatalf("Cannot write metadata: %v", err) + } + + if er := precompressDist(); err != nil { + log.Fatalf("Cannot precompress resources: %v", er) + } +} + +// 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 +} + +func precompressDist() error { + log.Printf("Pre-compressing files in dist/...\n") + var eg errgroup.Group + err := fs.WalkDir(os.DirFS("./"), "dist", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !compressibleExtensions[filepath.Ext(path)] { + return nil + } + log.Printf("Pre-compressing %v\n", path) + + eg.Go(func() error { + return precompress(path) + }) + return nil + }) + if err != nil { + return err + } + return eg.Wait() +} + +var compressibleExtensions = map[string]bool{ + ".js": true, + ".css": true, + ".wasm": true, +} + +func precompress(path string) error { + contents, err := os.ReadFile(path) + if err != nil { + return err + } + fi, err := os.Lstat(path) + if err != nil { + return err + } + + err = writeCompressed(contents, func(w io.Writer) (io.WriteCloser, error) { + return gzip.NewWriterLevel(w, gzip.BestCompression) + }, path+".gz", fi.Mode()) + if err != nil { + return err + } + return writeCompressed(contents, func(w io.Writer) (io.WriteCloser, error) { + return brotli.NewWriterLevel(w, brotli.BestCompression), nil + }, path+".br", fi.Mode()) +} + +func writeCompressed(contents []byte, compressedWriterCreator func(io.Writer) (io.WriteCloser, error), outputPath string, outputMode fs.FileMode) error { + var buf bytes.Buffer + compressedWriter, err := compressedWriterCreator(&buf) + if err != nil { + return err + } + if _, err := compressedWriter.Write(contents); err != nil { + return err + } + if err := compressedWriter.Close(); err != nil { + return err + } + return os.WriteFile(outputPath, buf.Bytes(), outputMode) +} diff --git a/cmd/tsconnect/common.go b/cmd/tsconnect/common.go new file mode 100644 index 000000000..c9a22f4eb --- /dev/null +++ b/cmd/tsconnect/common.go @@ -0,0 +1,105 @@ +// 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 main + +import ( + "fmt" + "log" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strconv" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +const ( + devMode = true + prodMode = false +) + +// commonSetup performs setup that is common to both dev and build modes. +func commonSetup(dev bool) (*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: %w", err) + } + } + if err := buildDeps(dev); err != nil { + return nil, fmt.Errorf("Cannot build deps: %w", 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(dev bool) error { + if err := copyWasmExec(); err != nil { + return fmt.Errorf("Cannot copy wasm_exec.js: %w", err) + } + if err := buildWasm(dev); err != nil { + return fmt.Errorf("Cannot build main.wasm: %w", err) + } + if err := installJSDeps(); err != nil { + return fmt.Errorf("Cannot install JS deps: %w", 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(dev bool) 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") + stdoutStderr, err := exec.Command("yarn").CombinedOutput() + if err != nil { + log.Printf("yarn failed: %s", stdoutStderr) + } + return err +} diff --git a/cmd/tsconnect/dev.go b/cmd/tsconnect/dev.go new file mode 100644 index 000000000..4cbe6dfed --- /dev/null +++ b/cmd/tsconnect/dev.go @@ -0,0 +1,38 @@ +// 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 main + +import ( + "log" + "net" + "strconv" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +func runDev() { + buildOptions, err := commonSetup(devMode) + 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() +} diff --git a/cmd/tsconnect/dist/placeholder b/cmd/tsconnect/dist/placeholder new file mode 100644 index 000000000..4af99d997 --- /dev/null +++ b/cmd/tsconnect/dist/placeholder @@ -0,0 +1,2 @@ +This is here to make sure the dist/ directory exists for the go:embed command +in serve.go. diff --git a/cmd/tsconnect/index.html b/cmd/tsconnect/index.html new file mode 100644 index 000000000..837098ea5 --- /dev/null +++ b/cmd/tsconnect/index.html @@ -0,0 +1,16 @@ + + + + + + + + + +
+ + + diff --git a/cmd/tsconnect/package.json b/cmd/tsconnect/package.json new file mode 100644 index 000000000..15151ad6c --- /dev/null +++ b/cmd/tsconnect/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/cmd/tsconnect/serve.go b/cmd/tsconnect/serve.go new file mode 100644 index 000000000..5f5faf8c0 --- /dev/null +++ b/cmd/tsconnect/serve.go @@ -0,0 +1,134 @@ +// 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 main + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "path" + "time" + + "tailscale.com/tsweb" +) + +//go:embed dist/* index.html +var embeddedFS embed.FS + +var serveStartTime = time.Now() + +func runServe() { + mux := http.NewServeMux() + + indexBytes, err := generateServeIndex() + if err != nil { + log.Fatalf("Could not generate index.html: %v", err) + } + mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.ServeContent(w, r, "index.html", serveStartTime, bytes.NewReader(indexBytes)) + })) + mux.Handle("/dist/", http.HandlerFunc(handleServeDist)) + tsweb.Debugger(mux) + + log.Printf("Listening on %s", *addr) + err = http.ListenAndServe(*addr, mux) + if err != nil { + log.Fatal(err) + } +} + +func generateServeIndex() ([]byte, error) { + log.Printf("Generating index.html...\n") + rawIndexBytes, err := embeddedFS.ReadFile("index.html") + if err != nil { + return nil, fmt.Errorf("Could not read index.html: %w", err) + } + + esbuildMetadataBytes, err := embeddedFS.ReadFile("dist/esbuild-metadata.json") + if err != nil { + return nil, fmt.Errorf("Could not read esbuild-metadata.json: %w", err) + } + var esbuildMetadata EsbuildMetadata + if err := json.Unmarshal(esbuildMetadataBytes, &esbuildMetadata); err != nil { + return nil, fmt.Errorf("Could not parse esbuild-metadata.json: %w", err) + } + entryPointsToHashedDistPaths := make(map[string]string) + for outputPath, output := range esbuildMetadata.Outputs { + if output.EntryPoint != "" { + entryPointsToHashedDistPaths[output.EntryPoint] = outputPath + } + } + + indexBytes := rawIndexBytes + for entryPointPath, defaultDistPath := range entryPointsToDefaultDistPaths { + hashedDistPath := entryPointsToHashedDistPaths[entryPointPath] + if hashedDistPath != "" { + indexBytes = bytes.ReplaceAll(indexBytes, []byte(defaultDistPath), []byte(hashedDistPath)) + } + } + + return indexBytes, nil +} + +// EsbuildMetadata is the subset of metadata struct (described by +// https://esbuild.github.io/api/#metafile) that we care about for mapping +// from entry points to hashed file names. +type EsbuildMetadata = struct { + Outputs map[string]struct { + EntryPoint string `json:"entryPoint,omitempty"` + } `json:"outputs,omitempty"` +} + +var entryPointsToDefaultDistPaths = map[string]string{ + "src/index.css": "dist/index.css", + "src/index.js": "dist/index.js", +} + +func handleServeDist(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path[1:] + var f fs.File + // Prefer pre-compressed versions generated during the build step. + if tsweb.AcceptsEncoding(r, "br") { + if brotliFile, err := embeddedFS.Open(p + ".br"); err == nil { + f = brotliFile + w.Header().Set("Content-Encoding", "br") + } + } + if f == nil && tsweb.AcceptsEncoding(r, "gzip") { + if gzipFile, err := embeddedFS.Open(p + ".gz"); err == nil { + f = gzipFile + w.Header().Set("Content-Encoding", "gzip") + } + } + + if f == nil { + if rawFile, err := embeddedFS.Open(r.URL.Path[1:]); err == nil { + f = rawFile + } else { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + } + defer f.Close() + + // fs.File does not claim to implement Seeker, but in practice it does. + fSeeker, ok := f.(io.ReadSeeker) + if !ok { + http.Error(w, "Not seekable", http.StatusInternalServerError) + return + } + + // Aggressively cache static assets, since we cache-bust our assets with + // hashed filenames. + w.Header().Set("Cache-Control", "public, max-age=31535996") + w.Header().Set("Vary", "Accept-Encoding") + + http.ServeContent(w, r, path.Base(r.URL.Path), serveStartTime, fSeeker) +} diff --git a/cmd/tsconnect/src/index.css b/cmd/tsconnect/src/index.css new file mode 100644 index 000000000..83cd9c6fe --- /dev/null +++ b/cmd/tsconnect/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/cmd/tsconnect/src/index.js b/cmd/tsconnect/src/index.js new file mode 100644 index 000000000..f5095f873 --- /dev/null +++ b/cmd/tsconnect/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/cmd/tsconnect/src/js-state-store.js b/cmd/tsconnect/src/js-state-store.js new file mode 100644 index 000000000..c0b509d2b --- /dev/null +++ b/cmd/tsconnect/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/cmd/tsconnect/src/login.js b/cmd/tsconnect/src/login.js new file mode 100644 index 000000000..fe6901914 --- /dev/null +++ b/cmd/tsconnect/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/cmd/tsconnect/src/notifier.js b/cmd/tsconnect/src/notifier.js new file mode 100644 index 000000000..71317f01e --- /dev/null +++ b/cmd/tsconnect/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/cmd/tsconnect/src/ssh.js b/cmd/tsconnect/src/ssh.js new file mode 100644 index 000000000..7604f4a17 --- /dev/null +++ b/cmd/tsconnect/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/cmd/tsconnect/tsconnect.go b/cmd/tsconnect/tsconnect.go new file mode 100644 index 000000000..6beb981ed --- /dev/null +++ b/cmd/tsconnect/tsconnect.go @@ -0,0 +1,60 @@ +// 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 tsconnect command builds and serves the static site that is generated for +// the Tailscale Connect JS/WASM 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 "tailscale.com/cmd/tsconnect" + +import ( + "flag" + "fmt" + "log" + "os" +) + +var ( + addr = flag.String("addr", ":9090", "address to listen on") +) + +func main() { + flag.Usage = usage + flag.Parse() + if len(flag.Args()) != 1 { + flag.Usage() + } + + switch flag.Arg(0) { + case "dev": + runDev() + case "build": + runBuild() + case "serve": + runServe() + default: + log.Printf("Unknown command: %s", flag.Arg(0)) + flag.Usage() + } +} + +func usage() { + fmt.Fprintf(os.Stderr, ` +usage: tsconnect {dev|build|serve} +`[1:]) + + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, ` + +tsconnect implements development/build/serving workflows for Tailscale Connect. +It can be invoked with one of three subcommands: + +- dev: Run in development mode, allowing JS and CSS changes to be picked up without a rebuilt or restart. +- build: Run in production build mode (generating static assets) +- serve: Run in production serve mode (serving static assets) +`[1:]) + os.Exit(2) +} diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go new file mode 100644 index 000000000..4fae64344 --- /dev/null +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -0,0 +1,411 @@ +// 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" + "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({ + notifyState(state: int): void, + notifyNetMap(netMap: object): void, + notifyBrowseToURL(url: 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) != 6 { + log.Printf("Usage: ssh(hostname, writeFn, readFn, rows, 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/cmd/tsconnect/yarn.lock b/cmd/tsconnect/yarn.lock new file mode 100644 index 000000000..8315985be --- /dev/null +++ b/cmd/tsconnect/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" diff --git a/go.mod b/go.mod index 65e40311d..dc707b329 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( filippo.io/mkcert v1.4.3 github.com/akutz/memconn v0.1.0 github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 + github.com/andybalholm/brotli v1.0.3 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be github.com/aws/aws-sdk-go-v2 v1.11.2 github.com/aws/aws-sdk-go-v2/config v1.11.0 @@ -16,6 +17,7 @@ require ( github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf github.com/creack/pty v1.1.17 github.com/dave/jennifer v1.4.1 + github.com/evanw/esbuild v0.14.39 github.com/frankban/quicktest v1.14.0 github.com/go-ole/go-ole v1.2.6 github.com/godbus/dbus/v5 v5.0.6 diff --git a/go.sum b/go.sum index 06d9ac39c..d77d628ca 100644 --- a/go.sum +++ b/go.sum @@ -112,6 +112,7 @@ github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pO github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/andybalholm/brotli v1.0.3 h1:fpcw+r1N1h0Poc1F/pHbW40cUm/lMEQslZtCkBQ0UnM= github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -280,6 +281,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= @@ -304,6 +307,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= @@ -343,9 +347,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= @@ -380,9 +386,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= @@ -839,6 +847,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= @@ -1149,6 +1159,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= @@ -1511,6 +1522,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=