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"