From 2cbcdc4ba8bae7704b11089993e831161dc64ce2 Mon Sep 17 00:00:00 2001 From: Mihai Parparita Date: Wed, 8 Jun 2022 22:55:55 -0700 Subject: [PATCH] wasm: implement Taildrop receiving We need to make sure that there's a filesystem (implemented by BrowserFS for now) and then things mostly work. File contents are sent to the JS side as base64 encoded data, which may not work for large files. Signed-off-by: Mihai Parparita --- ssh/browser/index.html | 1 + ssh/browser/package.json | 1 + ssh/browser/src/files.js | 18 +++++++ ssh/browser/src/fs.js | 101 ++++++++++++++++++++++++++++++++++++ ssh/browser/src/index.css | 13 +++++ ssh/browser/src/index.js | 28 +++++++--- ssh/browser/src/notifier.js | 13 +++++ ssh/browser/wasm/wasm_js.go | 39 ++++++++++++++ ssh/browser/yarn.lock | 25 +++++++++ 9 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 ssh/browser/src/files.js create mode 100644 ssh/browser/src/fs.js 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 @@
Loading…
+
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"