mirror of
https://github.com/tailscale/tailscale.git
synced 2024-12-11 10:44:41 +00:00
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:
parent
decc3ee30d
commit
2cbcdc4ba8
@ -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>
|
||||
|
@ -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
18
ssh/browser/src/files.js
Normal 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
101
ssh/browser/src/fs.js
Normal 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+",
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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() == "" {
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user