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=