summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--ssh/browser/index.html1
-rw-r--r--ssh/browser/package.json1
-rw-r--r--ssh/browser/src/files.js18
-rw-r--r--ssh/browser/src/fs.js101
-rw-r--r--ssh/browser/src/index.css13
-rw-r--r--ssh/browser/src/index.js28
-rw-r--r--ssh/browser/src/notifier.js13
-rw-r--r--ssh/browser/wasm/wasm_js.go39
-rw-r--r--ssh/browser/yarn.lock25
9 files changed, 232 insertions, 7 deletions
diff --git a/ssh/browser/index.html b/ssh/browser/index.html
index 0cdc01bf6..5cfa65930 100644
--- a/ssh/browser/index.html
+++ b/ssh/browser/index.html
@@ -11,6 +11,7 @@
<div id="state">Loading…</div>
</div>
<div id="peers"></div>
+ <div id="files"></div>
<script src="dist/index.js"></script>
</body>
</html>
diff --git a/ssh/browser/package.json b/ssh/browser/package.json
index 15151ad6c..4b5a6789e 100644
--- a/ssh/browser/package.json
+++ b/ssh/browser/package.json
@@ -2,6 +2,7 @@
"name": "@tailscale/ssh",
"version": "0.0.1",
"devDependencies": {
+ "browserfs": "^1.4.3",
"qrcode": "^1.5.0",
"xterm": "^4.18.0"
},
diff --git a/ssh/browser/src/files.js b/ssh/browser/src/files.js
new file mode 100644
index 000000000..444be7d92
--- /dev/null
+++ b/ssh/browser/src/files.js
@@ -0,0 +1,18 @@
+// 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.
+
+export function handleFile(file) {
+ const fileNode = document.createElement("div")
+ fileNode.addEventListener("click", () => fileNode.remove(), { once: true })
+ fileNode.className = "file"
+ fileNode.appendChild(document.createTextNode("Received file: "))
+
+ const linkNode = document.createElement("a")
+ linkNode.href = `data:;base64,${file.data}`
+ linkNode.download = file.name
+ linkNode.textContent = file.name
+ fileNode.appendChild(linkNode)
+
+ document.getElementById("files").appendChild(fileNode)
+}
diff --git a/ssh/browser/src/fs.js b/ssh/browser/src/fs.js
new file mode 100644
index 000000000..25be8669e
--- /dev/null
+++ b/ssh/browser/src/fs.js
@@ -0,0 +1,101 @@
+// 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 * as BrowserFS from "browserfs"
+
+export function injectFS() {
+ return new Promise((resolve, reject) => {
+ BrowserFS.configure({ fs: "InMemory" }, () => {
+ const goFs = globalThis.fs
+ const browserFs = BrowserFS.BFSRequire("fs")
+ const { Buffer } = BrowserFS.BFSRequire("buffer")
+ globalThis.fs = {
+ constants: {
+ O_WRONLY: 1,
+ O_RDWR: 2,
+ O_CREAT: 64,
+ O_TRUNC: 512,
+ O_APPEND: 1024,
+ O_EXCL: 128,
+ },
+ ...browserFs,
+ open(path, flags, mode, callback) {
+ if (typeof flags === "number") {
+ flags &= 0x1fff
+ if (flags in FLAGS_TO_PERMISSION_STRING_MAP) {
+ flags = FLAGS_TO_PERMISSION_STRING_MAP[flags]
+ } else {
+ console.warn(
+ `Unknown flags ${flags}, will not map to permission string`
+ )
+ }
+ }
+ return browserFs.open(path, flags, mode, callback)
+ },
+ writeSync(fd, buf) {
+ if (fd <= 2) {
+ return goFs.writeSync(fd, buf)
+ }
+ return browserFs.writeSync(fb, buf)
+ },
+ write(fd, buf, offset, length, position, callback) {
+ if (fd <= 2) {
+ return goFs.write(fd, buf, offset, length, position, callback)
+ }
+ return browserFs.write(
+ fd,
+ Buffer.from(buf),
+ offset,
+ length,
+ position,
+ callback
+ )
+ },
+ close(fd, callback) {
+ return browserFs.close(fd, (err) => {
+ callback(err === undefined ? null : err)
+ })
+ },
+ fstat(fd, callback) {
+ return browserFs.fstat(fd, (err, retStat) => {
+ delete retStat["fileData"]
+ retStat.atimeMs = retStat.atime.getTime()
+ retStat.mtimeMs = retStat.mtime.getTime()
+ retStat.ctimeMs = retStat.ctime.getTime()
+ retStat.birthtimeMs = retStat.birthtime.getTime()
+ return callback(err, retStat)
+ })
+ },
+ }
+
+ resolve()
+ })
+ })
+}
+
+const FLAGS_TO_PERMISSION_STRING_MAP = {
+ 0 /*O_RDONLY*/: "r",
+ 1 /*O_WRONLY*/: "r+",
+ 2 /*O_RDWR*/: "r+",
+ 64 /*O_CREAT*/: "r",
+ 65 /*O_WRONLY|O_CREAT*/: "r+",
+ 66 /*O_RDWR|O_CREAT*/: "r+",
+ 129 /*O_WRONLY|O_EXCL*/: "rx+",
+ 193 /*O_WRONLY|O_CREAT|O_EXCL*/: "rx+",
+ 514 /*O_RDWR|O_TRUNC*/: "w+",
+ 577 /*O_WRONLY|O_CREAT|O_TRUNC*/: "w",
+ 578 /*O_CREAT|O_RDWR|O_TRUNC*/: "w+",
+ 705 /*O_WRONLY|O_CREAT|O_EXCL|O_TRUNC*/: "wx",
+ 706 /*O_RDWR|O_CREAT|O_EXCL|O_TRUNC*/: "wx+",
+ 1024 /*O_APPEND*/: "a",
+ 1025 /*O_WRONLY|O_APPEND*/: "a",
+ 1026 /*O_RDWR|O_APPEND*/: "a+",
+ 1089 /*O_WRONLY|O_CREAT|O_APPEND*/: "a",
+ 1090 /*O_RDWR|O_CREAT|O_APPEND*/: "a+",
+ 1153 /*O_WRONLY|O_EXCL|O_APPEND*/: "ax",
+ 1154 /*O_RDWR|O_EXCL|O_APPEND*/: "ax+",
+ 1217 /*O_WRONLY|O_CREAT|O_EXCL|O_APPEND*/: "ax",
+ 1218 /*O_RDWR|O_CREAT|O_EXCL|O_APPEND*/: "ax+",
+ 4096 /*O_RDONLY|O_DSYNC*/: "rs",
+ 4098 /*O_RDWR|O_DSYNC*/: "rs+",
+}
diff --git a/ssh/browser/src/index.css b/ssh/browser/src/index.css
index 83cd9c6fe..ef0878780 100644
--- a/ssh/browser/src/index.css
+++ b/ssh/browser/src/index.css
@@ -89,3 +89,16 @@ button {
min-height: 20px;
background-color: #ffffff20;
}
+
+.file {
+ margin: 12px;
+ padding: 8px;
+ border-radius: 8px;
+ box-shadow: 1px 1px 3px rgb(0 0 0 / 50%);
+}
+
+#files {
+ position: absolute;
+ bottom: 12px;
+ right: 12px;
+}
diff --git a/ssh/browser/src/index.js b/ssh/browser/src/index.js
index f5095f873..72003963b 100644
--- a/ssh/browser/src/index.js
+++ b/ssh/browser/src/index.js
@@ -4,14 +4,25 @@
import "./wasm_exec"
import wasmUrl from "./main.wasm"
-import { notifyState, notifyNetMap, notifyBrowseToURL } from "./notifier"
+import {
+ notifyState,
+ notifyNetMap,
+ notifyBrowseToURL,
+ notifyIncomingFiles,
+} from "./notifier"
import { sessionStateStorage } from "./js-state-store"
+import { injectFS } from "./fs"
-const go = new window.Go()
-WebAssembly.instantiateStreaming(
- fetch(`./dist/${wasmUrl}`),
- go.importObject
-).then((result) => {
+async function main() {
+ // Inject in-memory filesystem (otherwise wasm_exec.js will use a stub that
+ // always returns errors).
+ await injectFS()
+
+ const go = new globalThis.Go()
+ const result = await WebAssembly.instantiateStreaming(
+ fetch(`./dist/${wasmUrl}`),
+ go.importObject
+ )
go.run(result.instance)
const ipn = newIPN({
// Persist IPN state in sessionStorage in development, so that we don't need
@@ -22,5 +33,8 @@ WebAssembly.instantiateStreaming(
notifyState: notifyState.bind(null, ipn),
notifyNetMap: notifyNetMap.bind(null, ipn),
notifyBrowseToURL: notifyBrowseToURL.bind(null, ipn),
+ notifyIncomingFiles: notifyIncomingFiles.bind(null, ipn),
})
-})
+}
+
+main()
diff --git a/ssh/browser/src/notifier.js b/ssh/browser/src/notifier.js
index 71317f01e..bef79a69f 100644
--- a/ssh/browser/src/notifier.js
+++ b/ssh/browser/src/notifier.js
@@ -9,6 +9,7 @@ import {
hideLogoutButton,
} from "./login"
import { showSSHPeers, hideSSHPeers } from "./ssh"
+import { handleFile } from "./files"
/**
* @fileoverview Notification callback functions (bridged from ipn.Notify)
@@ -73,3 +74,15 @@ export function notifyNetMap(ipn, netMapStr) {
export function notifyBrowseToURL(ipn, url) {
showLoginURL(url)
}
+
+export function notifyIncomingFiles(ipn, filesStr) {
+ const files = JSON.parse(filesStr)
+
+ if (DEBUG) {
+ console.log("Files: " + JSON.stringify(files, null, 2))
+ }
+
+ for (const file of files) {
+ handleFile(file)
+ }
+}
diff --git a/ssh/browser/wasm/wasm_js.go b/ssh/browser/wasm/wasm_js.go
index 2e86ed048..e1d66f7b2 100644
--- a/ssh/browser/wasm/wasm_js.go
+++ b/ssh/browser/wasm/wasm_js.go
@@ -12,9 +12,11 @@ package main
import (
"bytes"
"context"
+ "encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
+ "io"
"log"
"math/rand"
"net"
@@ -100,6 +102,11 @@ func newIPN(jsConfig js.Value) map[string]any {
}
lb := srv.LocalBackend()
+ // Actual path does not matter, we're using an in-memory file system on the
+ // JS side.
+ lb.SetVarRoot("/")
+ ns.SetLocalBackend(lb)
+
jsIPN := &jsIPN{
dialer: dialer,
srv: srv,
@@ -198,6 +205,32 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
if n.BrowseToURL != nil {
jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL)
}
+ if n.IncomingFiles != nil {
+ jsFiles := mapSlice(n.IncomingFiles, func(f ipn.PartialFile) *jsFile {
+ if rc, size, err := i.lb.OpenFile(f.Name); err == nil {
+ defer rc.Close()
+ buf := make([]byte, size)
+ if _, err := io.ReadFull(rc, buf); err == nil {
+ return &jsFile{
+ Name: f.Name,
+ Size: size,
+ Data: base64.StdEncoding.EncodeToString(buf),
+ }
+ } else {
+ log.Printf("Could not read file %s: %v", f.Name, err)
+ }
+ } else {
+ log.Printf("Could not open file %s: %v", f.Name, err)
+ }
+ return nil
+ })
+ jsFiles = filterSlice(jsFiles, func(f *jsFile) bool { return f != nil })
+ if jsonFiles, err := json.Marshal(jsFiles); err == nil {
+ jsCallbacks.Call("notifyIncomingFiles", string(jsonFiles))
+ } else {
+ log.Printf("Could not generate JSON files: %v", err)
+ }
+ }
})
go func() {
@@ -356,6 +389,12 @@ type jsStateStore struct {
jsStateStorage js.Value
}
+type jsFile struct {
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ Data string `json:"data"`
+}
+
func (s *jsStateStore) ReadState(id ipn.StateKey) ([]byte, error) {
jsValue := s.jsStateStorage.Call("getState", string(id))
if jsValue.String() == "" {
diff --git a/ssh/browser/yarn.lock b/ssh/browser/yarn.lock
index 8315985be..8861e881f 100644
--- a/ssh/browser/yarn.lock
+++ b/ssh/browser/yarn.lock
@@ -14,6 +14,21 @@ ansi-styles@^4.0.0:
dependencies:
color-convert "^2.0.1"
+async@^2.1.4:
+ version "2.6.4"
+ resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
+ integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
+ dependencies:
+ lodash "^4.17.14"
+
+browserfs@^1.4.3:
+ version "1.4.3"
+ resolved "https://registry.yarnpkg.com/browserfs/-/browserfs-1.4.3.tgz#92ffc6063967612daccdb8566d3fc03f521205fb"
+ integrity sha512-tz8HClVrzTJshcyIu8frE15cjqjcBIu15Bezxsvl/i+6f59iNCN3kznlWjz0FEb3DlnDx3gW5szxeT6D1x0s0w==
+ dependencies:
+ async "^2.1.4"
+ pako "^1.0.4"
+
camelcase@^5.0.0:
version "5.3.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
@@ -85,6 +100,11 @@ locate-path@^5.0.0:
dependencies:
p-locate "^4.1.0"
+lodash@^4.17.14:
+ version "4.17.21"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+ integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
+
p-limit@^2.2.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
@@ -104,6 +124,11 @@ p-try@^2.0.0:
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
+pako@^1.0.4:
+ version "1.0.11"
+ resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
+ integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
+
path-exists@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"