hasChild treated any href that was not the collection as a share, so a peer answering about an unrelated collection looked like it had shares. Follow RFC 4918 §9.1 instead: the collection comes first and anything after it is a member, with the first href counted only if it is itself below the root. Also accumulate href text across tokens; the XML decoder may split character data, which the previous token-at-a-time check miscounted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
5.8 KiB
Go
184 lines
5.8 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 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
|
|
}
|