Files
tailscale/cmd/tsconnect/wasm/drive.go
T
2026-08-30 23:32:56 +00:00

395 lines
10 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_drive
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"syscall/js"
"tailscale.com/drive"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
)
// Compile-time check that jsFileSystemForRemote implements drive.FileSystemForRemote.
var _ drive.FileSystemForRemote = (*jsFileSystemForRemote)(nil)
// jsFileSystemForRemote implements drive.FileSystemForRemote by bridging
// incoming WebDAV requests to a JS handler function. Auth and permission
// parsing are handled upstream by handleServeDrive before this is called.
type jsFileSystemForRemote struct {
mu sync.RWMutex
fn js.Value
}
type driveResponse struct {
mu sync.Mutex
w http.ResponseWriter
doneCh chan error
live bool
responseStarted bool
testBeforeWriteCheck func()
}
func (r *driveResponse) isLive() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.live
}
func (r *driveResponse) writeHead(args []js.Value) {
r.mu.Lock()
defer r.mu.Unlock()
if !r.live || len(args) < 1 {
return
}
status := args[0].Int()
if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() {
for k, vs := range jsHeadersToGo(args[1]) {
for _, v := range vs {
r.w.Header().Add(k, v)
}
}
}
r.w.WriteHeader(status)
r.responseStarted = true
}
func (r *driveResponse) write(args []js.Value) {
if r.testBeforeWriteCheck != nil {
r.testBeforeWriteCheck()
}
r.mu.Lock()
defer r.mu.Unlock()
if !r.live || len(args) < 1 {
return
}
data := args[0]
buf := make([]byte, data.Get("length").Int())
js.CopyBytesToGo(buf, data)
r.responseStarted = true
if _, err := r.w.Write(buf); err != nil {
select {
case r.doneCh <- err:
default:
}
return
}
if f, ok := r.w.(http.Flusher); ok {
f.Flush()
}
}
func (r *driveResponse) finish(resultErr, contextErr error) {
r.mu.Lock()
defer r.mu.Unlock()
if resultErr != nil && contextErr == nil && !r.responseStarted {
http.Error(r.w, "drive handler failed", http.StatusInternalServerError)
}
r.live = false
}
func (fs *jsFileSystemForRemote) setHandler(fn js.Value) {
fs.mu.Lock()
fs.fn = fn
fs.mu.Unlock()
}
// SetFileServerAddr is a no-op: the JS handler owns its own storage.
func (fs *jsFileSystemForRemote) SetFileServerAddr(_ string) {}
// SetShares is a no-op: the JS handler controls which shares it exposes.
func (fs *jsFileSystemForRemote) SetShares(_ []*drive.Share) {}
// Close is a no-op.
func (fs *jsFileSystemForRemote) Close() error { return nil }
// ServeHTTPWithPerms handles a WebDAV request by bridging it to the JS handler.
// It streams the request body to JS via readBodyChunk() and streams the
// response body back via write()/end() callbacks, so no full-body buffering
// occurs regardless of file size.
//
// The call blocks until JS calls end(), a write or handler error occurs, or
// the request context is cancelled.
func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
perms drive.Permissions, w http.ResponseWriter, r *http.Request,
) {
fs.mu.RLock()
fn := fs.fn
fs.mu.RUnlock()
if fn.Type() != js.TypeFunction {
http.NotFound(w, r)
return
}
response := &driveResponse{w: w, doneCh: make(chan error, 1), live: true}
// readBodyChunk is exposed to JS as req.readBodyChunk().
// Each call returns a Promise<Uint8Array|null>: null signals EOF.
readBodyChunk := js.FuncOf(func(_ js.Value, _ []js.Value) any {
if !response.isLive() {
return makePromise(func() (any, error) {
return js.Null(), nil
})
}
return makePromise(func() (any, error) {
buf := make([]byte, 65536)
n, err := r.Body.Read(buf)
if n > 0 {
arr := js.Global().Get("Uint8Array").New(n)
js.CopyBytesToJS(arr, buf[:n])
return arr, nil
}
if errors.Is(err, io.EOF) {
return js.Null(), nil
}
return nil, err
})
})
// writeHead sets response headers and status code. Must be called before write().
writeHead := js.FuncOf(func(_ js.Value, args []js.Value) any {
response.writeHead(args)
return nil
})
// write streams a single response body chunk to the client.
write := js.FuncOf(func(_ js.Value, args []js.Value) any {
response.write(args)
return nil
})
// end signals that the response is complete.
end := js.FuncOf(func(_ js.Value, _ []js.Value) any {
select {
case response.doneCh <- nil:
default:
}
return nil
})
var fulfilled, rejected js.Func
var handlerSettled sync.Once
settleHandler := func(err error) {
handlerSettled.Do(func() {
select {
case response.doneCh <- err:
default:
}
readBodyChunk.Release()
writeHead.Release()
write.Release()
end.Release()
fulfilled.Release()
rejected.Release()
})
}
fulfilled = js.FuncOf(func(_ js.Value, _ []js.Value) any {
settleHandler(nil)
return nil
})
rejected = js.FuncOf(func(_ js.Value, args []js.Value) any {
err := errors.New("JavaScript drive handler rejected")
if len(args) > 0 && args[0].Type() == js.TypeString {
err = fmt.Errorf("JavaScript drive handler rejected: %s", args[0].String())
}
settleHandler(err)
return nil
})
jsReq := map[string]any{
"method": r.Method,
"path": r.URL.Path,
"rawQuery": r.URL.RawQuery,
"headers": goHeadersToJS(r.Header),
"readBodyChunk": readBodyChunk,
}
jsRes := map[string]any{
"writeHead": writeHead,
"write": write,
"end": end,
}
result, handlerErr := callJSFunction(fn, jsReq, jsRes, drivePermsToJS(perms))
if handlerErr == nil {
if hasThen, err := hasJSFunctionProperty(result, "then"); err != nil {
handlerErr = err
} else if hasThen {
_, handlerErr = callJSMethod(result, "then", fulfilled, rejected)
} else {
settleHandler(nil)
}
}
if handlerErr != nil {
settleHandler(handlerErr)
}
var resultErr error
select {
case resultErr = <-response.doneCh:
case <-r.Context().Done():
resultErr = r.Context().Err()
}
response.finish(resultErr, r.Context().Err())
}
// drivePermsToJS converts drive.Permissions to a plain JS-friendly object.
// Each share name maps to a numeric permission: 0=none, 1=read-only, 2=read-write.
// The wildcard share name "*" is included if present.
func drivePermsToJS(p drive.Permissions) map[string]any {
result := make(map[string]any, len(p))
for name, perm := range p {
result[name] = int(perm)
}
return result
}
// goHeadersToJS converts an http.Header to a map[string]any suitable for JS.
// Single-value headers become a string; multi-value headers become a []any.
func goHeadersToJS(h http.Header) map[string]any {
result := make(map[string]any, len(h))
for k, vs := range h {
if len(vs) == 1 {
result[k] = vs[0]
} else {
arr := make([]any, len(vs))
for i, v := range vs {
arr[i] = v
}
result[k] = arr
}
}
return result
}
// jsHeadersToGo parses a JS headers object into an http.Header map.
// Values may be a string or an array of strings.
func jsHeadersToGo(jsHeaders js.Value) http.Header {
h := make(http.Header)
keys := js.Global().Get("Object").Call("keys", jsHeaders)
for i := 0; i < keys.Length(); i++ {
key := keys.Index(i).String()
val := jsHeaders.Get(key)
switch val.Type() {
case js.TypeString:
h.Set(key, val.String())
case js.TypeObject:
if val.InstanceOf(js.Global().Get("Array")) {
for j := 0; j < val.Length(); j++ {
h.Add(key, val.Index(j).String())
}
}
}
}
return h
}
// initDriveForRemote creates the JS-backed FileSystemForRemote and registers
// it with sys. Must be called before NewLocalBackend (SubSystem is set-once).
func initDriveForRemote(sys *tsd.System) *jsFileSystemForRemote {
driveFS := &jsFileSystemForRemote{}
sys.Set(driveFS)
return driveFS
}
// wireDriveJS adds drive-related methods to the IPN JS methods map.
// driveFS must be the value returned by initDriveForRemote.
func wireDriveJS(i *jsIPN, driveFS *jsFileSystemForRemote, m map[string]any) {
m["setDriveHandler"] = js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return nil
}
driveFS.setHandler(args[0])
return nil
})
m["listDrivePeers"] = js.FuncOf(func(_ js.Value, _ []js.Value) any {
return i.listDrivePeers()
})
}
type jsDrivePeer struct {
Name string `json:"name"`
PeerAPIURL string `json:"peerAPIURL"`
StableNodeID string `json:"stableNodeID"`
Online *bool `json:"online,omitempty"`
}
// listDrivePeers returns a JSON array of peers that are online, have a
// reachable peerAPI and carry PeerCapabilityTaildriveSharer. Returns an empty
// array if the local node does not have drive:access in its ACL
// (DriveAccessEnabled). This mirrors the filtering in
// LocalBackend.driveRemotesFromPeers.
//
// The cap means a peer is allowed to share with us, not that it currently
// exposes any share, so the result is a superset of the peers with shares.
func (i *jsIPN) listDrivePeers() js.Value {
return makePromise(func() (any, error) {
if !i.lb.DriveAccessEnabled() {
return "[]", nil
}
nm := i.lb.NetMap()
if nm == nil {
return nil, errors.New("listDrivePeers: no network map available")
}
var selfHave4, selfHave6 bool
for _, a := range nm.GetAddresses().All() {
if !a.IsSingleIP() {
continue
}
if a.Addr().Is4() {
selfHave4 = true
} else if a.Addr().Is6() {
selfHave6 = true
}
}
peers := make([]jsDrivePeer, 0)
for _, p := range nm.Peers {
if !p.Online().Get() {
continue
}
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
if peerURL == "" {
continue
}
// Check PeerCapabilityTaildriveSharer via the live PeerCaps map
// (derived from ACL rules), mirroring driveRemotesFromPeers.
hasCap := false
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && i.lb.PeerCaps(a.Addr()).HasCapability(tailcfg.PeerCapabilityTaildriveSharer) {
hasCap = true
break
}
}
if !hasCap {
continue
}
online := p.Online().Clone()
peers = append(peers, jsDrivePeer{
Name: p.DisplayName(false),
PeerAPIURL: peerURL,
StableNodeID: string(p.StableID()),
Online: online,
})
}
b, err := json.Marshal(peers)
if err != nil {
return nil, fmt.Errorf("listDrivePeers: marshal: %w", err)
}
return string(b), nil
})
}