// 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 }