PeerCapabilityTaildriveSharer says a peer may share with us, not that it
does, so listDrivePeers is a superset of the peers actually exposing
shares. listDrivePeers now takes an options object; with
{hasShares: true} each candidate's taildrive root is probed with a
Depth-1 PROPFIND and only peers listing at least one share are kept.
The probe and its multistatus parsing live in cmd/tsconnect/driveprobe
so they can be tested without syscall/js. Probes run in parallel with a
bounded worker count, a per-probe timeout and a bounded response read;
a probe that fails drops that peer and is logged rather than failing
the call, so the filter is positive-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
349 lines
9.3 KiB
Go
349 lines
9.3 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !ts_omit_drive
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"sync"
|
|
"syscall/js"
|
|
|
|
"tailscale.com/cmd/tsconnect/driveprobe"
|
|
"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
|
|
}
|
|
|
|
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() (or a write error occurs).
|
|
func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
|
|
perms drive.Permissions, w http.ResponseWriter, r *http.Request,
|
|
) {
|
|
fs.mu.RLock()
|
|
fn := fs.fn
|
|
fs.mu.RUnlock()
|
|
|
|
if fn.IsUndefined() || fn.IsNull() {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// 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 {
|
|
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
|
|
})
|
|
})
|
|
|
|
// doneCh receives nil when JS calls end(), or a write error if Write fails.
|
|
doneCh := make(chan error, 1)
|
|
|
|
// writeHead sets response headers and status code. Must be called before write().
|
|
writeHead := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
|
if len(args) < 1 {
|
|
return nil
|
|
}
|
|
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 {
|
|
w.Header().Add(k, v)
|
|
}
|
|
}
|
|
}
|
|
w.WriteHeader(status)
|
|
return nil
|
|
})
|
|
|
|
// write streams a single response body chunk to the client.
|
|
write := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
|
if len(args) < 1 {
|
|
return nil
|
|
}
|
|
data := args[0]
|
|
buf := make([]byte, data.Get("length").Int())
|
|
js.CopyBytesToGo(buf, data)
|
|
if _, werr := w.Write(buf); werr != nil {
|
|
select {
|
|
case doneCh <- werr:
|
|
default:
|
|
}
|
|
return nil
|
|
}
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// end signals that the response is complete.
|
|
end := js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
|
select {
|
|
case doneCh <- nil:
|
|
default:
|
|
}
|
|
return nil
|
|
})
|
|
|
|
defer func() {
|
|
readBodyChunk.Release()
|
|
writeHead.Release()
|
|
write.Release()
|
|
end.Release()
|
|
}()
|
|
|
|
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,
|
|
}
|
|
|
|
fn.Invoke(jsReq, jsRes, drivePermsToJS(perms))
|
|
|
|
// Block this goroutine until JS calls end() or a write error occurs.
|
|
// The Go WASM scheduler yields back to JS while we wait.
|
|
<-doneCh
|
|
}
|
|
|
|
// 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, args []js.Value) any {
|
|
var hasShares bool
|
|
if len(args) > 0 && !args[0].IsUndefined() && !args[0].IsNull() {
|
|
if v := args[0].Get("hasShares"); v.Type() == js.TypeBoolean {
|
|
hasShares = v.Bool()
|
|
}
|
|
}
|
|
return i.listDrivePeers(hasShares)
|
|
})
|
|
}
|
|
|
|
// filterPeersWithShares keeps only the peers whose Taildrive endpoint lists at
|
|
// least one share for us, preserving the order of the input.
|
|
func (i *jsIPN) filterPeersWithShares(peers []jsDrivePeer) []jsDrivePeer {
|
|
urls := make([]string, len(peers))
|
|
for n, p := range peers {
|
|
urls[n] = p.PeerAPIURL
|
|
}
|
|
|
|
client := &http.Client{Transport: i.lb.Dialer().PeerAPITransport()}
|
|
results := driveprobe.HasSharesMulti(context.Background(), client, urls, log.Printf)
|
|
|
|
kept := make([]jsDrivePeer, 0, len(peers))
|
|
for n, p := range peers {
|
|
if results[n] {
|
|
kept = append(kept, p)
|
|
}
|
|
}
|
|
return kept
|
|
}
|
|
|
|
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.
|
|
//
|
|
// hasShares narrows it to peers we confirmed are exposing at least one share,
|
|
// at the cost of one peerAPI round-trip per candidate (run in parallel). It is
|
|
// a positive filter: a peer we could not reach is left out, which is not the
|
|
// same as knowing it has no shares.
|
|
func (i *jsIPN) listDrivePeers(hasShares bool) 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,
|
|
})
|
|
}
|
|
|
|
if hasShares && len(peers) > 0 {
|
|
peers = i.filterPeersWithShares(peers)
|
|
}
|
|
|
|
b, err := json.Marshal(peers)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listDrivePeers: marshal: %w", err)
|
|
}
|
|
return string(b), nil
|
|
})
|
|
}
|