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>
162 lines
5.0 KiB
Go
162 lines
5.0 KiB
Go
// 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
|
|
}
|