diff --git a/cmd/tsconnect/driveprobe/driveprobe.go b/cmd/tsconnect/driveprobe/driveprobe.go
new file mode 100644
index 000000000..70cc56bc7
--- /dev/null
+++ b/cmd/tsconnect/driveprobe/driveprobe.go
@@ -0,0 +1,183 @@
+// 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 = `` +
+ ``
+
+// 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 lists anything besides the
+// collection we asked about. 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.
+//
+// RFC 4918 §9.1 says a Depth-1 PROPFIND answers with the collection itself
+// followed by its members, so anything after the first href is a share. The
+// first href counts only if it is itself below root, which catches a peer that
+// answers about a subtree rather than the collection we asked for.
+//
+// A peer that omits the collection entirely is not understood: ipnlocal strips
+// the taildrive prefix before handing the request to the share server, so a
+// real peer's members are named "/docs" rather than "/v0/drive/docs" and a lone
+// member is indistinguishable from the collection. Such a peer is reported as
+// having no shares, which is the safe direction for a positive filter.
+func hasChild(body io.Reader, root string) (bool, error) {
+ dec := xml.NewDecoder(body)
+ var href string
+ inHref, first := false, true
+ 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:
+ if t.Name.Space == "DAV:" && t.Name.Local == "href" {
+ inHref, href = true, ""
+ }
+ case xml.CharData:
+ // Character data can arrive in several tokens for one element.
+ if inHref {
+ href += string(t)
+ }
+ case xml.EndElement:
+ if !inHref {
+ continue
+ }
+ inHref = false
+ if !first {
+ return true, nil
+ }
+ first = false
+ if isBelow(href, root) {
+ return true, nil
+ }
+ }
+ }
+}
+
+// isBelow reports whether href points below root. Peers may answer with an
+// absolute URL or a path, percent-encoded and with or without a trailing
+// slash, so compare cleaned paths 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
+ }
+ return r == "/" || strings.HasPrefix(p, r+"/")
+}
+
+// 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
+}
diff --git a/cmd/tsconnect/driveprobe/driveprobe_test.go b/cmd/tsconnect/driveprobe/driveprobe_test.go
new file mode 100644
index 000000000..4265a943e
--- /dev/null
+++ b/cmd/tsconnect/driveprobe/driveprobe_test.go
@@ -0,0 +1,263 @@
+// 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(``)
+ for _, h := range hrefs {
+ fmt.Fprintf(&b, `%sHTTP/1.1 200 OK`, h)
+ }
+ b.WriteString(``)
+ 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},
+ // The collection comes first per RFC 4918 §9.1, so anything after it
+ // is a share whatever the peer names it.
+ {"unrelated collection href, no members", multistatus("/somewhere/else/"), false},
+ {"unrelated collection href with a member", multistatus("/somewhere/else/", "/somewhere/else/docs"), true},
+ // A peer that omits the collection from a Depth-1 listing violates
+ // RFC 4918 §9.1, and once the taildrive prefix is stripped there is
+ // nothing left to tell its lone member apart from the collection. It
+ // loses the benefit of the doubt: hasShares excludes what it cannot
+ // confirm.
+ {"single member, collection omitted", multistatus("/docs"), false},
+ {"href split by an entity reference", multistatus("/v0/drive/", "/v0/drive/a&b"), true},
+ {"empty href", multistatus("/v0/drive/", ""), true},
+ }
+ 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"), "")
+ // 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("= 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)
+ }
+}
diff --git a/cmd/tsconnect/wasm/drive.go b/cmd/tsconnect/wasm/drive.go
index dea1b53f8..a48c834dd 100644
--- a/cmd/tsconnect/wasm/drive.go
+++ b/cmd/tsconnect/wasm/drive.go
@@ -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)
diff --git a/cmd/tsconnect/wasm/drive_stub.go b/cmd/tsconnect/wasm/drive_stub.go
index a9f3101d7..9c2fcddde 100644
--- a/cmd/tsconnect/wasm/drive_stub.go
+++ b/cmd/tsconnect/wasm/drive_stub.go
@@ -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
})
diff --git a/wasm b/wasm
new file mode 100755
index 000000000..f10431ed1
Binary files /dev/null and b/wasm differ