tailscale/ssh/browser/browser.go
Mihai Parparita decc3ee30d ssh/browser: implement browser client for Tailscale SSH
Runs a Tailscale client in the browser (via a WebAssembly build of the
wasm package) and allows SSH access to machines. The wasm package exports
a newIPN function, which returns a simple JS object with methods like
start(), login(), logout() and ssh(). The golang.org/x/crypto/ssh
package is used for the SSH client.

Terminal emulation and QR code renedring is done via NPM packages (xterm
and qrcode respectively), thus we also need a JS toolchain that can
install and bundle them. Yarn is used for installation, and esbuild
handles loading them and bundling for production serving.

Updates #3157

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-06-08 15:29:41 -07:00

214 lines
5.7 KiB
Go

// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Builds and serves the static site that is generated for the browser/Wasm
// Tailscale SSH client. Can be run in 3 modes:
// - dev: builds the site and serves it. JS and CSS changes can be picked up
// with a reload.
// - build: builds the site and writes it to dist/
// - serve: serves the site from dist/ (embedded in the binary)
package main
import (
"embed"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strconv"
esbuild "github.com/evanw/esbuild/pkg/api"
)
var (
dev = flag.Bool("dev", false, "Run in dev build and serve mode")
build = flag.Bool("build", false, "Run in production build mode (generating static assets)")
serve = flag.Bool("serve", false, "Run in production serve mode (serving static assets)")
addr = flag.String("addr", ":9090", "address to listen on")
)
func main() {
flag.Parse()
if *dev {
runDev()
} else if *build {
runBuild()
} else if *serve {
runServe()
} else {
log.Fatal("No mode specified")
}
}
func runDev() {
buildOptions, err := commonSetup()
if err != nil {
log.Fatalf("Cannot setup: %v", err)
}
host, portStr, err := net.SplitHostPort(*addr)
if err != nil {
log.Fatalf("Cannot parse addr: %v", err)
}
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
log.Fatalf("Cannot parse port: %v", err)
}
result, err := esbuild.Serve(esbuild.ServeOptions{
Port: uint16(port),
Host: host,
Servedir: "./",
}, *buildOptions)
if err != nil {
log.Fatalf("Cannot start esbuild server: %v", err)
}
log.Printf("Listening on http://%s:%d\n", result.Host, result.Port)
result.Wait()
}
func runBuild() {
buildOptions, err := commonSetup()
if err != nil {
log.Fatalf("Cannot setup: %v", err)
}
if err := cleanDist(); err != nil {
log.Fatalf("Cannot clean dist/: %v", err)
}
buildOptions.Write = true
buildOptions.MinifyWhitespace = true
buildOptions.MinifyIdentifiers = true
buildOptions.MinifySyntax = true
result := esbuild.Build(*buildOptions)
if len(result.Errors) > 0 {
log.Printf("ESBuild Error:\n")
for _, e := range result.Errors {
log.Printf("%v", e)
}
log.Fatal("Build failed")
}
if len(result.Warnings) > 0 {
log.Printf("ESBuild Warnings:\n")
for _, w := range result.Warnings {
log.Printf("%v", w)
}
}
}
//go:embed dist/* index.html
var embeddedFS embed.FS
func runServe() {
log.Printf("Listening on %s", *addr)
err := http.ListenAndServe(*addr, http.FileServer(http.FS(embeddedFS)))
if err != nil {
log.Fatal(err)
}
}
// commonSetup performs setup that is common to both dev and build modes.
func commonSetup() (*esbuild.BuildOptions, error) {
// Change cwd to to where this file lives -- that's where all inputs for
// esbuild and other build steps live.
if _, filename, _, ok := runtime.Caller(0); ok {
if err := os.Chdir(path.Dir(filename)); err != nil {
return nil, fmt.Errorf("Cannot change cwd: %v", err)
}
}
if err := buildDeps(); err != nil {
return nil, fmt.Errorf("Cannot build deps: %v", err)
}
return &esbuild.BuildOptions{
EntryPoints: []string{"src/index.js", "src/index.css"},
Loader: map[string]esbuild.Loader{".wasm": esbuild.LoaderFile},
Outdir: "./dist",
Bundle: true,
Sourcemap: esbuild.SourceMapLinked,
LogLevel: esbuild.LogLevelInfo,
Define: map[string]string{"DEBUG": strconv.FormatBool(*dev)},
Target: esbuild.ES2017,
}, nil
}
// buildDeps builds the static assets that are needed for the server (except for
// JS/CSS bundling, which is handled by esbuild).
func buildDeps() error {
if err := copyWasmExec(); err != nil {
return fmt.Errorf("Cannot copy wasm_exec.js: %v", err)
}
if err := buildWasm(); err != nil {
return fmt.Errorf("Cannot build main.wasm: %v", err)
}
if err := installJsDeps(); err != nil {
return fmt.Errorf("Cannot install JS deps: %v", err)
}
return nil
}
// copyWasmExec grabs the current wasm_exec.js runtime helper library from the
// Go toolchain.
func copyWasmExec() error {
log.Printf("Copying wasm_exec.js...\n")
wasmExecSrcPath := filepath.Join(runtime.GOROOT(), "misc", "wasm", "wasm_exec.js")
wasmExecDstPath := filepath.Join("src", "wasm_exec.js")
contents, err := os.ReadFile(wasmExecSrcPath)
if err != nil {
return err
}
return os.WriteFile(wasmExecDstPath, contents, 0600)
}
// buildWasm builds the Tailscale wasm binary and places it where the JS can
// load it.
func buildWasm() error {
log.Printf("Building wasm...\n")
args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,nethttpomithttp2,omitidna,omitpemdecrypt"}
if !*dev {
// Omit long paths and debug symbols in release builds, to reduce the
// generated WASM binary size.
args = append(args, "-trimpath", "-ldflags", "-s -w")
}
args = append(args, "-o", "src/main.wasm", "./wasm")
cmd := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), args...)
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// installJsDeps installs the JavaScript dependencies specified by package.json
func installJsDeps() error {
log.Printf("Installing JS deps...\n")
return exec.Command("yarn").Run()
}
// cleanDist removes files from the dist build directory, except the placeholder
// one that we keep to make sure Git still creates the directory.
func cleanDist() error {
log.Printf("Cleaning dist/...\n")
files, err := os.ReadDir("dist")
if err != nil {
return err
}
for _, file := range files {
if file.Name() != "placeholder" {
if err := os.Remove(filepath.Join("dist", file.Name())); err != nil {
return err
}
}
}
return nil
}