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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user