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>main
parent
2a22ea3e83
commit
6f5096fa61
@ -0,0 +1,4 @@ |
|||||||
|
src/wasm_exec.js |
||||||
|
src/main.wasm |
||||||
|
node_modules/ |
||||||
|
dist/ |
||||||
@ -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) |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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() |
||||||
|
} |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
This is here to make sure the dist/ directory exists for the go:embed command |
||||||
|
in serve.go. |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html> |
||||||
|
<head> |
||||||
|
<meta charset="utf-8" /> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||||
|
<link rel="stylesheet" type="text/css" href="dist/index.css" /> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div id="header"> |
||||||
|
<h1>Tailscale Connect</h1> |
||||||
|
<div id="state">Loading…</div> |
||||||
|
</div> |
||||||
|
<div id="peers"></div> |
||||||
|
<script src="dist/index.js"></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -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 |
||||||
|
} |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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), |
||||||
|
}) |
||||||
|
}) |
||||||
@ -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}`] || "" |
||||||
|
}, |
||||||
|
} |
||||||
@ -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 |
||||||
@ -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) |
||||||
|
} |
||||||
@ -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() |
||||||
|
} |
||||||
|
) |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
@ -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" |
||||||
Loading…
Reference in new issue