feat(tsconnect/wasm): add hasShares filter to listDrivePeers

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>
This commit is contained in:
2026-08-02 23:32:03 +00:00
co-authored by Claude
parent 7e9868f50e
commit 3b239fe9e2
5 changed files with 454 additions and 4 deletions
+161
View File
@@ -0,0 +1,161 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package driveprobe reports whether a peer currently exposes Taildrive
// shares, by asking its peerAPI rather than by inspecting ACL capabilities.
//
// PeerCapabilityTaildriveSharer only says a peer is allowed to share with us.
// The share list itself is only visible over WebDAV, so this package issues a
// Depth-1 PROPFIND against the peer's Taildrive root and looks for children.
// The peer applies our permissions before listing, so a child is a share we
// can actually reach.
//
// This lives outside the wasm package so it can be tested without syscall/js.
package driveprobe
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
"tailscale.com/types/logger"
)
const (
// drivePath is the peerAPI prefix taildrive is served under.
drivePath = "/v0/drive/"
// maxProbes bounds how many probes are in flight at once. Go under wasm
// runs on a single thread, so a high limit buys little and costs memory.
maxProbes = 8
// maxResponseBytes bounds the listing we are willing to read. A peer with
// a plausible number of shares is far below this.
maxResponseBytes = 1 << 20
// probeTimeout bounds a single probe in HasSharesMulti, so one peer that
// accepts the connection and then stalls cannot hold up the listing.
probeTimeout = 5 * time.Second
)
// propfindBody asks only for resourcetype: we care whether children exist,
// not what they are.
const propfindBody = `<?xml version="1.0" encoding="utf-8"?>` +
`<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>`
// HasShares reports whether the peer at peerAPIURL exposes at least one
// Taildrive share to us.
//
// A false result means the peer answered and listed nothing. An error means we
// could not find out — callers must not read it as "no shares".
func HasShares(ctx context.Context, c *http.Client, peerAPIURL string) (bool, error) {
u := strings.TrimSuffix(peerAPIURL, "/") + drivePath
req, err := http.NewRequestWithContext(ctx, "PROPFIND", u, strings.NewReader(propfindBody))
if err != nil {
return false, err
}
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := c.Do(req)
if err != nil {
return false, err
}
defer func() {
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes))
resp.Body.Close()
}()
// Anything other than 207 is the peer declining to list: taildrive off
// (404), no cap for us (403), or a handler that does not speak WebDAV.
if resp.StatusCode != http.StatusMultiStatus {
return false, fmt.Errorf("PROPFIND %s: %s", u, resp.Status)
}
return hasChild(io.LimitReader(resp.Body, maxResponseBytes), drivePath)
}
// hasChild reports whether a multistatus body contains a response for anything
// below root. It decodes as a stream and stops at the first child, so a peer
// with many shares costs no more than a peer with one.
func hasChild(body io.Reader, root string) (bool, error) {
dec := xml.NewDecoder(body)
inHref := false
for {
tok, err := dec.Token()
if err == io.EOF {
return false, nil
}
if err != nil {
return false, fmt.Errorf("parse multistatus: %w", err)
}
switch t := tok.(type) {
case xml.StartElement:
inHref = t.Name.Space == "DAV:" && t.Name.Local == "href"
case xml.EndElement:
inHref = false
case xml.CharData:
if inHref && isBelow(string(t), root) {
return true, nil
}
}
}
}
// isBelow reports whether href points below root. Peers may answer with an
// absolute URL or a path, percent-encoded, with or without a trailing slash,
// and may or may not include the taildrive prefix we asked under, so compare
// cleaned path segments rather than strings.
func isBelow(href, root string) bool {
u, err := url.Parse(strings.TrimSpace(href))
if err != nil {
return false
}
p := path.Clean("/" + strings.Trim(u.Path, "/"))
r := path.Clean("/" + strings.Trim(root, "/"))
if p == r || p == "/" {
return false
}
// A peer that strips the prefix answers "/share"; one that keeps it
// answers "/v0/drive/share". Both are a share.
return true
}
// HasSharesMulti probes every URL and returns one result per input, in input
// order. A probe that fails is reported as false and logged: the caller is
// filtering to peers we positively confirmed, and one unreachable peer must
// not sink the rest.
func HasSharesMulti(ctx context.Context, c *http.Client, urls []string, logf logger.Logf) []bool {
out := make([]bool, len(urls))
var mu sync.Mutex
// Deliberately not errgroup.WithContext: a failing probe must not cancel
// its siblings.
var g errgroup.Group
g.SetLimit(maxProbes)
for i, u := range urls {
g.Go(func() error {
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
ok, err := HasShares(ctx, c, u)
if err != nil {
logf("driveprobe: %s: %v", u, err)
return nil
}
mu.Lock()
out[i] = ok
mu.Unlock()
return nil
})
}
g.Wait()
return out
}
+251
View File
@@ -0,0 +1,251 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package driveprobe
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func multistatus(hrefs ...string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?><D:multistatus xmlns:D="DAV:">`)
for _, h := range hrefs {
fmt.Fprintf(&b, `<D:response><D:href>%s</D:href><D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>`, h)
}
b.WriteString(`</D:multistatus>`)
return b.String()
}
func TestHasChild(t *testing.T) {
tests := []struct {
name string
body string
want bool
}{
{"root only, prefix stripped", multistatus("/"), false},
{"root only, prefix kept", multistatus("/v0/drive/"), false},
{"root only, no trailing slash", multistatus("/v0/drive"), false},
{"one share, prefix stripped", multistatus("/", "/docs"), true},
{"one share, prefix kept", multistatus("/v0/drive/", "/v0/drive/docs"), true},
{"absolute urls", multistatus("http://100.1.2.3:1234/v0/drive/", "http://100.1.2.3:1234/v0/drive/docs"), true},
{"percent-encoded share name", multistatus("/v0/drive/", "/v0/drive/my%20share"), true},
{"unicode share name", multistatus("/v0/drive/", "/v0/drive/%E6%97%A5%E6%9C%AC"), true},
{"empty multistatus", multistatus(), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := hasChild(strings.NewReader(tt.body), drivePath)
if err != nil {
t.Fatalf("hasChild: %v", err)
}
if got != tt.want {
t.Errorf("hasChild = %v, want %v", got, tt.want)
}
})
}
}
func TestHasChildMalformed(t *testing.T) {
body := strings.TrimSuffix(multistatus("/v0/drive/", "/v0/drive/docs"), "</D:multistatus>")
// Truncation after a child href still answers the question.
got, err := hasChild(strings.NewReader(body), drivePath)
if err != nil {
t.Fatalf("hasChild: %v", err)
}
if !got {
t.Error("hasChild = false on a truncated body that already listed a share")
}
if _, err := hasChild(strings.NewReader("<D:multistatus"), drivePath); err == nil {
t.Error("hasChild on malformed XML: want error, got nil")
}
}
// serveDrive returns a server answering PROPFIND at the taildrive root with the
// given body and status, and records the requests it saw.
func serveDrive(t *testing.T, status int, body string) (*httptest.Server, *[]*http.Request) {
t.Helper()
var mu sync.Mutex
var reqs []*http.Request
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
reqs = append(reqs, r)
mu.Unlock()
w.WriteHeader(status)
io := []byte(body)
w.Write(io)
}))
t.Cleanup(srv.Close)
return srv, &reqs
}
func TestHasShares(t *testing.T) {
srv, reqs := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/", "/v0/drive/docs"))
got, err := HasShares(context.Background(), srv.Client(), srv.URL)
if err != nil {
t.Fatalf("HasShares: %v", err)
}
if !got {
t.Error("HasShares = false, want true")
}
if len(*reqs) != 1 {
t.Fatalf("got %d requests, want 1", len(*reqs))
}
r := (*reqs)[0]
if r.Method != "PROPFIND" {
t.Errorf("method = %q, want PROPFIND", r.Method)
}
if r.URL.Path != drivePath {
t.Errorf("path = %q, want %q", r.URL.Path, drivePath)
}
if d := r.Header.Get("Depth"); d != "1" {
t.Errorf("Depth = %q, want 1", d)
}
}
func TestHasSharesTrailingSlashInPeerURL(t *testing.T) {
srv, reqs := serveDrive(t, http.StatusMultiStatus, multistatus("/"))
if _, err := HasShares(context.Background(), srv.Client(), srv.URL+"/"); err != nil {
t.Fatalf("HasShares: %v", err)
}
if p := (*reqs)[0].URL.Path; p != drivePath {
t.Errorf("path = %q, want %q", p, drivePath)
}
}
func TestHasSharesNonMultiStatus(t *testing.T) {
// Taildrive disabled, or we hold no cap on that peer.
for _, status := range []int{http.StatusNotFound, http.StatusForbidden, http.StatusOK} {
srv, _ := serveDrive(t, status, "")
got, err := HasShares(context.Background(), srv.Client(), srv.URL)
if err == nil {
t.Errorf("status %d: want error, got nil", status)
}
if got {
t.Errorf("status %d: HasShares = true", status)
}
}
}
func TestHasSharesCancelled(t *testing.T) {
// The handler must also unblock on release: a client-side cancel does not
// reliably reach the server's request context, and Close waits for it.
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-release:
}
}))
defer srv.Close()
defer close(release)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, err := HasShares(ctx, srv.Client(), srv.URL); err == nil {
t.Error("want error on cancelled probe, got nil")
}
}
func TestHasSharesMultiOrderAndFailures(t *testing.T) {
withShares, _ := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/", "/v0/drive/docs"))
noShares, _ := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/"))
refused, _ := serveDrive(t, http.StatusNotFound, "")
urls := []string{noShares.URL, withShares.URL, refused.URL, "http://127.0.0.1:1/dead", withShares.URL}
want := []bool{false, true, false, false, true}
var logs atomic.Int32
got := HasSharesMulti(context.Background(), withShares.Client(), urls, func(string, ...any) {
logs.Add(1)
})
for i := range want {
if got[i] != want[i] {
t.Errorf("result[%d] = %v, want %v (%s)", i, got[i], want[i], urls[i])
}
}
// The 404 peer and the dead address are both reported, not swallowed.
if n := logs.Load(); n != 2 {
t.Errorf("logged %d probe failures, want 2", n)
}
}
func TestHasSharesMultiRunsInParallel(t *testing.T) {
const delay = 100 * time.Millisecond
var inFlight, peak atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := inFlight.Add(1)
for {
old := peak.Load()
if n <= old || peak.CompareAndSwap(old, n) {
break
}
}
time.Sleep(delay)
inFlight.Add(-1)
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(multistatus("/v0/drive/", "/v0/drive/docs")))
}))
defer srv.Close()
urls := make([]string, maxProbes)
for i := range urls {
urls[i] = srv.URL
}
start := time.Now()
got := HasSharesMulti(context.Background(), srv.Client(), urls, func(string, ...any) {})
elapsed := time.Since(start)
for i, ok := range got {
if !ok {
t.Errorf("result[%d] = false, want true", i)
}
}
if elapsed >= delay*time.Duration(len(urls)) {
t.Errorf("probes serialized: %v for %d probes of %v each", elapsed, len(urls), delay)
}
if peak.Load() < 2 {
t.Errorf("peak concurrency = %d, want >= 2", peak.Load())
}
}
func TestHasSharesMultiLimitsConcurrency(t *testing.T) {
var inFlight, peak atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := inFlight.Add(1)
for {
old := peak.Load()
if n <= old || peak.CompareAndSwap(old, n) {
break
}
}
time.Sleep(20 * time.Millisecond)
inFlight.Add(-1)
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(multistatus("/v0/drive/")))
}))
defer srv.Close()
urls := make([]string, maxProbes*3)
for i := range urls {
urls[i] = srv.URL
}
client := &http.Client{Transport: &http.Transport{MaxConnsPerHost: 0}}
HasSharesMulti(context.Background(), client, urls, func(string, ...any) {})
if peak.Load() > maxProbes {
t.Errorf("peak concurrency = %d, want <= %d", peak.Load(), maxProbes)
}
}
+41 -3
View File
@@ -6,14 +6,17 @@
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"
@@ -228,11 +231,37 @@ func wireDriveJS(i *jsIPN, driveFS *jsFileSystemForRemote, m map[string]any) {
return nil
})
m["listDrivePeers"] = js.FuncOf(func(_ js.Value, _ []js.Value) any {
return i.listDrivePeers()
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"`
@@ -248,7 +277,12 @@ type jsDrivePeer struct {
//
// 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 {
//
// 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
@@ -301,6 +335,10 @@ func (i *jsIPN) listDrivePeers() js.Value {
})
}
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)
+1 -1
View File
@@ -20,7 +20,7 @@ func initDriveForRemote(_ *tsd.System) *jsFileSystemForRemote { return nil }
func wireDriveJS(_ *jsIPN, _ *jsFileSystemForRemote, _ map[string]any) {}
// listDrivePeers returns an empty list when the drive feature is omitted.
func (i *jsIPN) listDrivePeers() js.Value {
func (i *jsIPN) listDrivePeers(_ bool) js.Value {
return makePromise(func() (any, error) {
return "[]", nil
})
Executable
BIN
View File
Binary file not shown.