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