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 <mihai@tailscale.com>
This commit is contained in:
Mihai Parparita 2022-06-08 22:55:55 -07:00
parent decc3ee30d
commit 2cbcdc4ba8
9 changed files with 232 additions and 7 deletions

View File

@ -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>

View File

@ -2,6 +2,7 @@
"name": "@tailscale/ssh",
"version": "0.0.1",
"devDependencies": {
"browserfs": "^1.4.3",
"qrcode": "^1.5.0",
"xterm": "^4.18.0"
},

18
ssh/browser/src/files.js Normal file
View File

@ -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)
}

101
ssh/browser/src/fs.js Normal file
View File

@ -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+",
}

View File

@ -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;
}

View File

@ -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()

View File

@ -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)
}
}

View File

@ -12,9 +12,11 @@
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() == "" {

View File

@ -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"