WIP: rebase fork onto upstream/main (v1.103.0) #15

Closed
codinget wants to merge 670 commits from webnet into save/webnet-2026-07-29
6 changed files with 800 additions and 109 deletions
Showing only changes of commit 1d69894084 - Show all commits
+53 -20
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"io/fs"
"iter"
"log"
"net"
"net/http"
@@ -456,10 +457,48 @@ func (r *remote) ServeHTTP(w http.ResponseWriter, req *http.Request) {
}
type system struct {
t *testing.T
local *local
client *gowebdav.Client
t *testing.T
local *local
client *gowebdav.Client
transport http.RoundTripper
mu sync.Mutex
remotes map[string]*remote
gen uint64
}
// Domain implements [drive.RemoteSource].
func (s *system) Domain() string { return domain }
// Transport implements [drive.RemoteSource].
func (s *system) Transport() http.RoundTripper { return s.transport }
// Remotes implements [drive.RemoteSource].
func (s *system) Remotes() iter.Seq[*drive.Remote] {
s.mu.Lock()
rs := make([]*drive.Remote, 0, len(s.remotes))
for name, r := range s.remotes {
url := fmt.Sprintf("http://%s", r.ln.Addr())
rs = append(rs, &drive.Remote{
Name: name,
URL: func() string { return url },
})
}
s.mu.Unlock()
return func(yield func(*drive.Remote) bool) {
for _, r := range rs {
if !yield(r) {
return
}
}
}
}
// Generation implements [drive.RemoteSource].
func (s *system) Generation() uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.gen
}
func newSystem(t *testing.T) *system {
@@ -486,11 +525,16 @@ func newSystem(t *testing.T) *system {
client := gowebdav.NewAuthClient(fmt.Sprintf("http://%s", ln.Addr()), &noopAuthorizer{})
client.SetTransport(&http.Transport{DisableKeepAlives: true})
s := &system{
t: t,
local: &local{ln: ln, fs: fs},
t: t,
local: &local{ln: ln, fs: fs},
transport: &http.Transport{
DisableKeepAlives: true,
ResponseHeaderTimeout: 5 * time.Second,
},
client: client,
remotes: make(map[string]*remote),
}
fs.SetRemoteSource(s)
t.Cleanup(s.stop)
return s
}
@@ -518,22 +562,11 @@ func (s *system) addRemote(name string) string {
}
r.fs.SetFileServerAddr(fileServer.Addr())
go http.Serve(ln, r)
s.remotes[name] = r
remotes := make([]*drive.Remote, 0, len(s.remotes))
for name, r := range s.remotes {
remotes = append(remotes, &drive.Remote{
Name: name,
URL: func() string { return fmt.Sprintf("http://%s", r.ln.Addr()) },
})
}
s.local.fs.SetRemotes(
domain,
remotes,
&http.Transport{
DisableKeepAlives: true,
ResponseHeaderTimeout: 5 * time.Second,
})
s.mu.Lock()
s.remotes[name] = r
s.gen++
s.mu.Unlock()
return fileServer.Addr()
}
+55 -11
View File
@@ -8,6 +8,7 @@ import (
"log"
"net"
"net/http"
"sync"
"time"
"tailscale.com/drive"
@@ -52,10 +53,18 @@ type FileSystemForLocal struct {
logf logger.Logf
h *compositedav.Handler
listener *connListener
// sourceMu guards source and cachedGen. It also serializes the
// rebuild path so concurrent requests don't race to replace
// children with stale data.
sourceMu sync.Mutex
source drive.RemoteSource
cachedGen uint64
haveGen bool // true once cachedGen reflects an actual source.Generation call
}
func (s *FileSystemForLocal) startServing() {
hs := &http.Server{Handler: s.h}
hs := &http.Server{Handler: http.HandlerFunc(s.serveHTTP)}
go func() {
err := hs.Serve(s.listener)
if err != nil {
@@ -65,17 +74,35 @@ func (s *FileSystemForLocal) startServing() {
}()
}
// HandleConn handles connections from local WebDAV clients
func (s *FileSystemForLocal) HandleConn(conn net.Conn, remoteAddr net.Addr) error {
return s.listener.HandleConn(conn, remoteAddr)
// serveHTTP refreshes the underlying compositedav children from the
// remote source if its generation has changed, then delegates to the
// composite handler. The refresh path is skipped entirely when the
// generation is unchanged, which is the common case.
func (s *FileSystemForLocal) serveHTTP(w http.ResponseWriter, r *http.Request) {
s.refresh()
s.h.ServeHTTP(w, r)
}
// SetRemotes sets the complete set of remotes on the given tailnet domain
// using a map of name -> url. If transport is specified, that transport
// will be used to connect to these remotes.
func (s *FileSystemForLocal) SetRemotes(domain string, remotes []*drive.Remote, transport http.RoundTripper) {
children := make([]*compositedav.Child, 0, len(remotes))
for _, remote := range remotes {
// refresh rebuilds the compositedav children from the current source
// if its generation has changed since the last refresh. It is a no-op
// when no source is set or when the generation matches the cached
// value.
func (s *FileSystemForLocal) refresh() {
s.sourceMu.Lock()
defer s.sourceMu.Unlock()
source := s.source
if source == nil {
return
}
gen := source.Generation()
if s.haveGen && gen == s.cachedGen {
return
}
transport := source.Transport()
var children []*compositedav.Child
for remote := range source.Remotes() {
children = append(children, &compositedav.Child{
Child: &dirfs.Child{
Name: remote.Name,
@@ -85,8 +112,25 @@ func (s *FileSystemForLocal) SetRemotes(domain string, remotes []*drive.Remote,
Transport: transport,
})
}
s.h.SetChildren(source.Domain(), children...)
s.cachedGen = gen
s.haveGen = true
}
s.h.SetChildren(domain, children...)
// HandleConn handles connections from local WebDAV clients
func (s *FileSystemForLocal) HandleConn(conn net.Conn, remoteAddr net.Addr) error {
return s.listener.HandleConn(conn, remoteAddr)
}
// SetRemoteSource sets the source from which the filesystem reads the
// current set of remotes. It replaces any previously set source and
// forces a rebuild on the next incoming request.
func (s *FileSystemForLocal) SetRemoteSource(source drive.RemoteSource) {
s.sourceMu.Lock()
s.source = source
s.cachedGen = 0
s.haveGen = false
s.sourceMu.Unlock()
}
// Close() stops serving the WebDAV content
+35 -4
View File
@@ -10,6 +10,7 @@
package drive
import (
"iter"
"net"
"net/http"
)
@@ -21,16 +22,46 @@ type Remote struct {
Available func() bool
}
// RemoteSource provides the current set of remote Taildrive nodes
// on demand. The drive filesystem consults Generation on each request
// and only rebuilds its internal child list when the value differs
// from the previously cached one. This lets callers avoid the
// per-netmap O(n) rebuild that an eager SetRemotes call would
// require.
//
// All methods may be called concurrently.
type RemoteSource interface {
// Domain returns the current tailnet domain under which remotes
// appear as sub-folders.
Domain() string
// Transport returns the http.RoundTripper used to reach remotes.
Transport() http.RoundTripper
// Remotes yields the current set of remote nodes.
// It is called by the drive filesystem only when Generation has
// changed since the last call.
Remotes() iter.Seq[*Remote]
// Generation returns a monotonically-increasing counter that
// changes whenever the values returned by Domain, Transport, or
// Remotes might have changed. The drive filesystem reads it on
// every request and skips the rebuild path entirely when it
// matches the previously-cached value.
Generation() uint64
}
// FileSystemForLocal is the Taildrive filesystem exposed to local clients. It
// provides a unified WebDAV interface to remote Taildrive shares on other nodes.
type FileSystemForLocal interface {
// HandleConn handles connections from local WebDAV clients
HandleConn(conn net.Conn, remoteAddr net.Addr) error
// SetRemotes sets the complete set of remotes on the given tailnet domain
// using a map of name -> url. If transport is specified, that transport
// will be used to connect to these remotes.
SetRemotes(domain string, remotes []*Remote, transport http.RoundTripper)
// SetRemoteSource sets the source from which the filesystem
// reads the current set of remotes. The source is consulted
// lazily on incoming WebDAV requests, so a stale cap or empty
// tailnet costs nothing per netmap update.
SetRemoteSource(source RemoteSource)
// Close() stops serving the WebDAV content
Close() error
+105 -70
View File
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"io"
"iter"
"net/http"
"net/netip"
"os"
@@ -18,20 +19,41 @@ import (
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/httpm"
)
func init() {
hookSetNetMapLockedDrive.Set(setNetMapLockedDrive)
hookInstallDriveRemoteSource.Set(installDriveRemoteSource)
}
func setNetMapLockedDrive(b *LocalBackend, nm *netmap.NetworkMap) {
b.updateDrivePeersLocked(nm)
// setNetMapLockedDrive runs on every full netmap install (the only path that
// can flip self caps or change the tailnet domain) to re-notify IPN bus
// listeners of the current local shares.
//
// It deliberately does NOT touch the remotes list passed to the local drive
// filesystem: that flows through [driveRemoteSource], which the filesystem
// pulls from lazily and only when something might have changed. See
// [LocalBackend.driveGen].
func setNetMapLockedDrive(b *LocalBackend) {
b.driveNotifyCurrentSharesLocked()
}
// installDriveRemoteSource registers a [drive.RemoteSource] on the local
// Taildrive filesystem so it can pull the current set of remotes on demand
// instead of being pushed a fresh list on every netmap update.
//
// It is wired from [NewLocalBackend] via [hookInstallDriveRemoteSource] so
// non-drive builds don't reference the drive package at all.
func installDriveRemoteSource(b *LocalBackend) {
fs, ok := b.sys.DriveForLocal.GetOK()
if !ok {
return
}
fs.SetRemoteSource(driveRemoteSource{b})
}
// DriveSetServerAddr tells Taildrive to use the given address for connecting
// to the drive.FileServer that's exposing local files as an unprivileged
// user.
@@ -285,80 +307,93 @@ func (b *LocalBackend) DriveGetShares() views.SliceView[*drive.Share, drive.Shar
return b.pm.prefs.DriveShares()
}
// updateDrivePeersLocked sets all applicable peers from the netmap as Taildrive
// remotes.
func (b *LocalBackend) updateDrivePeersLocked(nm *netmap.NetworkMap) {
fs, ok := b.sys.DriveForLocal.GetOK()
if !ok {
return
}
// driveRemoteSource implements [drive.RemoteSource] by reading from a
// [LocalBackend]. It is installed once on the local Taildrive filesystem
// at [NewLocalBackend] time and consulted lazily on incoming WebDAV
// requests.
//
// The filesystem only rebuilds its internal child list when [Generation]
// changes, so peer churn that doesn't affect the drive-capable peer set
// (e.g. endpoint or online-status mutations on non-drive peers) costs
// nothing more than a single atomic load.
type driveRemoteSource struct{ b *LocalBackend }
var driveRemotes []*drive.Remote
if b.DriveAccessEnabled() {
// Only populate peers if access is enabled, otherwise leave blank.
driveRemotes = b.driveRemotesFromPeers(nm)
}
fs.SetRemotes(nm.Domain, driveRemotes, b.newDriveTransport())
// Generation implements [drive.RemoteSource].
func (s driveRemoteSource) Generation() uint64 {
return s.b.driveGen.Load()
}
func (b *LocalBackend) driveRemotesFromPeers(nm *netmap.NetworkMap) []*drive.Remote {
b.logf("[v1] taildrive: setting up drive remotes from %d peers", len(nm.Peers))
driveRemotes := make([]*drive.Remote, 0, len(nm.Peers))
for _, p := range nm.Peers {
peer := p
peerID := peer.ID()
peerKey := peer.Key().ShortString()
peerName := peer.DisplayName(false)
// Domain implements [drive.RemoteSource].
func (s driveRemoteSource) Domain() string {
nm := s.b.currentNode().NetMap()
if nm == nil {
return ""
}
return nm.Domain
}
driveRemotes = append(driveRemotes, &drive.Remote{
Name: peerName,
URL: func() string {
url := fmt.Sprintf("%s/%s", b.currentNode().PeerAPIBase(peer), taildrivePrefix[1:])
b.logf("[v2] taildrive: url for peer %s (%s): %s", peerKey, peerName, url)
return url
},
Available: func() bool {
// Peers are available to Taildrive if:
// - They are online
// - Their PeerAPI is reachable
// - They are allowed to share at least one folder with us
cn := b.currentNode()
peer, ok := cn.NodeByID(peerID)
if !ok {
b.logf("[v2] taildrive: peer %s (%s, id=%v) not found", peerKey, peerName, peerID)
return false
}
// Transport implements [drive.RemoteSource].
func (s driveRemoteSource) Transport() http.RoundTripper {
return s.b.newDriveTransport()
}
// Exclude offline peers.
// TODO(oxtoacart): for some reason, this correctly
// catches when a node goes from offline to online,
// but not the other way around...
// TODO(oxtoacart,nickkhyl): the reason was probably
// that we were using netmap.Peers instead of b.peers.
// The netmap.Peers slice is not updated in all cases.
// It should be fixed now that we use PeerByIDOk.
if !peer.Online().Get() {
b.logf("[v2] taildrive: peer %s (%s, id=%v) offline", peerKey, peerName, peerID)
return false
}
if cn.PeerAPIBase(peer) == "" {
b.logf("[v2] taildrive: peer %s (%s, id=%v) PeerAPI unreachable", peerKey, peerName, peerID)
// Remotes implements [drive.RemoteSource]. It yields one [*drive.Remote]
// per peer; the per-Remote Available closure filters out peers that
// aren't online, don't expose PeerAPI, or haven't granted us the
// Taildrive sharer cap. If drive access is disabled on this node, no
// remotes are yielded at all, so the filesystem ends up with an empty
// child list.
func (s driveRemoteSource) Remotes() iter.Seq[*drive.Remote] {
return func(yield func(*drive.Remote) bool) {
b := s.b
if !b.DriveAccessEnabled() {
return
}
cn := b.currentNode()
peers := cn.Peers()
b.logf("[v1] taildrive: building drive remotes from %d peers", len(peers))
for _, peer := range peers {
peerID := peer.ID()
peerKey := peer.Key().ShortString()
peerName := peer.DisplayName(false)
r := &drive.Remote{
Name: peerName,
URL: func() string {
url := fmt.Sprintf("%s/%s", cn.PeerAPIBase(peer), taildrivePrefix[1:])
b.logf("[v2] taildrive: url for peer %s (%s): %s", peerKey, peerName, url)
return url
},
Available: func() bool {
// Peers are available to Taildrive if:
// - They are online
// - Their PeerAPI is reachable
// - They are allowed to share at least one folder with us
peer, ok := cn.NodeByID(peerID)
if !ok {
b.logf("[v2] taildrive: peer %s (%s, id=%v) not found", peerKey, peerName, peerID)
return false
}
if !peer.Online().Get() {
b.logf("[v2] taildrive: peer %s (%s, id=%v) offline", peerKey, peerName, peerID)
return false
}
if cn.PeerAPIBase(peer) == "" {
b.logf("[v2] taildrive: peer %s (%s, id=%v) PeerAPI unreachable", peerKey, peerName, peerID)
return false
}
if cn.PeerHasCap(peer, tailcfg.PeerCapabilityTaildriveSharer) {
b.logf("[v2] taildrive: peer %s (%s, id=%v) available", peerKey, peerName, peerID)
return true
}
b.logf("[v2] taildrive: peer %s (%s, id=%v) not allowed to share", peerKey, peerName, peerID)
return false
}
// Check that the peer is allowed to share with us.
if cn.PeerHasCap(peer, tailcfg.PeerCapabilityTaildriveSharer) {
b.logf("[v2] taildrive: peer %s (%s, id=%v) available", peerKey, peerName, peerID)
return true
}
b.logf("[v2] taildrive: peer %s (%s, id=%v) not allowed to share", peerKey, peerName, peerID)
return false
},
})
},
}
if !yield(r) {
return
}
}
}
b.logf("[v1] taildrive: built %d candidate remotes", len(driveRemotes))
return driveRemotes
}
// responseBodyWrapper wraps an io.ReadCloser and stores
+494
View File
@@ -7,9 +7,33 @@ package ipnlocal
import (
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"slices"
"sync"
"testing"
"time"
"github.com/studio-b12/gowebdav"
"tailscale.com/control/controlclient"
"tailscale.com/drive"
"tailscale.com/drive/driveimpl"
"tailscale.com/ipn"
"tailscale.com/ipn/store/mem"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/tstest"
"tailscale.com/types/ipproto"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/eventbus/eventbustest"
"tailscale.com/util/set"
"tailscale.com/wgengine"
"tailscale.com/wgengine/filter/filtertype"
)
// TestDriveTransportRoundTrip_NetworkError tests that driveTransport.RoundTrip
@@ -48,3 +72,473 @@ type mockRoundTripper struct {
func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return nil, m.err
}
// TestDriveGenBumps verifies that driveGen increments at each of the three
// call sites the [driveRemoteSource] cache invalidation depends on:
// full netmap installs, netmap deltas, and packet-filter updates. If any of
// these stops bumping, WebDAV clients would see a stale remote list until
// some other event happened to bump the counter, so the test asserts each
// site independently.
func TestDriveGenBumps(t *testing.T) {
b := newTestLocalBackend(t)
assertBumped := func(name string, fn func()) {
t.Helper()
before := b.driveGen.Load()
fn()
after := b.driveGen.Load()
if after <= before {
t.Errorf("%s: driveGen = %d after, %d before; want strictly greater", name, after, before)
}
}
selfNode := (&tailcfg.Node{
ID: 1,
Key: makeNodeKeyFromID(1),
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View()
peer2 := (&tailcfg.Node{
ID: 2,
Key: makeNodeKeyFromID(2),
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")},
}).View()
assertBumped("setNetMapLocked", func() {
b.mu.Lock()
defer b.mu.Unlock()
b.setNetMapLocked(&netmap.NetworkMap{
SelfNode: selfNode,
Peers: []tailcfg.NodeView{peer2},
})
})
assertBumped("UpdateNetmapDelta", func() {
muts, ok := netmap.MutationsFromMapResponse(&tailcfg.MapResponse{
OnlineChange: map[tailcfg.NodeID]bool{peer2.ID(): true},
}, time.Time{})
if !ok {
t.Fatal("MutationsFromMapResponse failed")
}
if !b.UpdateNetmapDelta(muts) {
t.Fatal("UpdateNetmapDelta returned false")
}
})
assertBumped("UpdatePacketFilter", func() {
if !b.UpdatePacketFilter(views.Slice[tailcfg.FilterRule]{}, nil) {
t.Fatal("UpdatePacketFilter returned false")
}
})
}
// TestDriveRemoteSourceAccessGate verifies that [driveRemoteSource.Remotes]
// yields zero entries when the self node lacks NodeAttrsTaildriveAccess, and
// the full peer set when it has it. This is the only path that decides
// whether the local Taildrive root shows any folders at all, so a regression
// here would silently break access for every user.
func TestDriveRemoteSourceAccessGate(t *testing.T) {
b := newTestLocalBackend(t)
selfNode := (&tailcfg.Node{
ID: 1,
Key: makeNodeKeyFromID(1),
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View()
peers := []tailcfg.NodeView{
(&tailcfg.Node{
ID: 2,
Key: makeNodeKeyFromID(2),
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")},
}).View(),
(&tailcfg.Node{
ID: 3,
Key: makeNodeKeyFromID(3),
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.3/32")},
}).View(),
}
install := func(allCaps set.Set[tailcfg.NodeCapability]) {
b.mu.Lock()
defer b.mu.Unlock()
b.setNetMapLocked(&netmap.NetworkMap{
SelfNode: selfNode,
Peers: peers,
AllCaps: allCaps,
})
}
src := driveRemoteSource{b: b}
collect := func() []*drive.Remote {
var out []*drive.Remote
for r := range src.Remotes() {
out = append(out, r)
}
return out
}
install(nil)
if got := collect(); len(got) != 0 {
t.Errorf("Remotes without DriveAccess cap: got %d entries, want 0", len(got))
}
install(set.Of(tailcfg.NodeAttrsTaildriveAccess))
if got := collect(); len(got) != len(peers) {
t.Errorf("Remotes with DriveAccess cap: got %d entries, want %d", len(got), len(peers))
}
}
// captureFS wraps a real [drive.FileSystemForLocal] but records the most
// recent [drive.RemoteSource] installed via SetRemoteSource. It lets the
// test below observe what NewLocalBackend wires up without poking at
// LocalBackend internals.
type captureFS struct {
drive.FileSystemForLocal
mu sync.Mutex
source drive.RemoteSource
}
func (c *captureFS) SetRemoteSource(source drive.RemoteSource) {
c.mu.Lock()
c.source = source
c.mu.Unlock()
c.FileSystemForLocal.SetRemoteSource(source)
}
func (c *captureFS) lastSource() drive.RemoteSource {
c.mu.Lock()
defer c.mu.Unlock()
return c.source
}
// TestDriveRemoteSourceInstalled verifies that NewLocalBackend wires a
// [driveRemoteSource] into sys.DriveForLocal via the
// hookInstallDriveRemoteSource init hook. Without this wiring, every
// remote-set update would silently no-op because the filesystem would
// have no source to pull from.
func TestDriveRemoteSourceInstalled(t *testing.T) {
bus := eventbustest.NewBus(t)
sys := tsd.NewSystemWithBus(bus)
cf := &captureFS{FileSystemForLocal: driveimpl.NewFileSystemForLocal(logger.Discard)}
sys.Set(drive.FileSystemForLocal(cf))
t.Cleanup(func() { cf.FileSystemForLocal.Close() })
b := newTestLocalBackendWithSys(t, sys)
src := cf.lastSource()
if src == nil {
t.Fatal("SetRemoteSource was never called on FileSystemForLocal")
}
drs, ok := src.(driveRemoteSource)
if !ok {
t.Fatalf("installed source is %T, want driveRemoteSource", src)
}
if drs.b != b {
t.Errorf("driveRemoteSource.b = %p, want LocalBackend %p", drs.b, b)
}
}
// driveEndToEndHarness wires up:
//
// - a real [LocalBackend] backed by a mock controlclient, so a full netmap
// can be delivered through the same code path the real controlclient
// uses (SetControlClientStatus → setNetMapLocked → updateFilterLocked);
//
// - a real [driveimpl.FileSystemForLocal] injected into [tsd.System]
// before NewLocalBackend, so installDriveRemoteSource fires; and
//
// - a TCP listener that hands accepted connections to fs.HandleConn,
// plus a gowebdav client pointed at it, so WebDAV PROPFINDs traverse
// the same code path a real Mac/Windows client would.
type driveEndToEndHarness struct {
t *testing.T
b *LocalBackend
cc *mockControl
fs drive.FileSystemForLocal
client *gowebdav.Client
domain string
selfAddr netip.Addr // self IPv4 single-IP address
// wg tracks the listener's accept loop and every per-connection
// fs.HandleConn goroutine, so t.Cleanup can Wait for them after
// closing the listener and prevent goroutine leaks across tests.
wg sync.WaitGroup
}
func newDriveEndToEndHarness(t *testing.T) *driveEndToEndHarness {
bus := eventbustest.NewBus(t)
sys := tsd.NewSystemWithBus(bus)
logf := logger.Discard
sys.Set(new(mem.Store))
eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get())
if err != nil {
t.Fatalf("NewFakeUserspaceEngine: %v", err)
}
t.Cleanup(eng.Close)
sys.Set(eng)
fs := driveimpl.NewFileSystemForLocal(logf)
sys.Set(drive.FileSystemForLocal(fs))
t.Cleanup(func() { fs.Close() })
b := newLocalBackendWithSysAndTestControl(t, false, sys, func(tb testing.TB, opts controlclient.Options) controlclient.Client {
return newClient(tb, opts)
})
if err := b.Start(ipn.Options{}); err != nil {
t.Fatalf("(*LocalBackend).Start: %v", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen: %v", err)
}
client := gowebdav.NewClient(fmt.Sprintf("http://%s", ln.Addr()), "", "")
client.SetTransport(&http.Transport{DisableKeepAlives: true})
h := &driveEndToEndHarness{
t: t,
b: b,
cc: b.cc.(*mockControl),
fs: fs,
client: client,
domain: "example.com",
selfAddr: netip.MustParseAddr("100.64.0.1"),
}
t.Cleanup(h.wg.Wait) // runs last, after ln.Close etc
t.Cleanup(func() { ln.Close() })
h.wg.Go(func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
h.wg.Go(func() { fs.HandleConn(conn, conn.RemoteAddr()) })
}
})
return h
}
// peerSpec describes a peer to be installed in a test netmap.
// driveCap controls whether the packet filter is built with a rule that
// grants the peer the [tailcfg.PeerCapabilityTaildriveSharer] cap to self.
type peerSpec struct {
id tailcfg.NodeID
name string
addr netip.Addr
online bool
driveCap bool
peerAPI bool
}
func (s peerSpec) node() *tailcfg.Node {
n := &tailcfg.Node{
ID: s.id,
Name: s.name + ".example.com.",
Key: makeNodeKeyFromID(s.id),
Addresses: []netip.Prefix{netip.PrefixFrom(s.addr, s.addr.BitLen())},
Online: new(s.online),
}
if s.peerAPI {
hi := &tailcfg.Hostinfo{
Services: []tailcfg.Service{{
Proto: tailcfg.PeerAPI4,
Port: 12345,
}},
}
n.Hostinfo = hi.View()
}
// The controlclient calls InitDisplayNames before delivering peers to
// LocalBackend. The mockControl path used here skips that step, so we
// do it explicitly to get DisplayName(false) → ComputedName ("alpha"
// etc.) rather than the node-key fallback.
n.InitDisplayNames("example.com")
return n
}
// filterMatchesFor builds the parsed packet-filter matches that grant
// PeerCapabilityTaildriveSharer from each driveCap-enabled peer's address
// to the self address. PeerHasCap reads its result, so this is what flips a
// peer in or out of the drive-capable set in the e2e test.
func (h *driveEndToEndHarness) filterMatchesFor(specs []peerSpec) []filtertype.Match {
var matches []filtertype.Match
for _, s := range specs {
if !s.driveCap {
continue
}
matches = append(matches, filtertype.Match{
IPProto: views.SliceOf([]ipproto.Proto{ipproto.TCP}),
Srcs: []netip.Prefix{netip.PrefixFrom(s.addr, s.addr.BitLen())},
Caps: []filtertype.CapMatch{{
Dst: netip.PrefixFrom(h.selfAddr, h.selfAddr.BitLen()),
Cap: tailcfg.PeerCapabilityTaildriveSharer,
}},
})
}
return matches
}
// installNetMap pushes a netmap built from specs through the mock control
// client. The self node always has Addresses and NodeAttrsTaildriveAccess
// in AllCaps. The packet filter is generated to grant
// PeerCapabilityTaildriveSharer from each driveCap-enabled peer's IP to
// the self IP.
func (h *driveEndToEndHarness) installNetMap(specs []peerSpec) {
h.t.Helper()
peers := make([]tailcfg.NodeView, 0, len(specs))
for _, s := range specs {
peers = append(peers, s.node().View())
}
nm := &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
ID: 1,
Name: "self.example.com.",
Key: makeNodeKeyFromID(1),
Addresses: []netip.Prefix{netip.PrefixFrom(h.selfAddr, h.selfAddr.BitLen())},
}).View(),
Domain: h.domain,
Peers: peers,
AllCaps: set.Of(tailcfg.NodeAttrsTaildriveAccess),
PacketFilter: h.filterMatchesFor(specs),
}
h.cc.send(sendOpt{loginFinished: true, nm: nm})
}
// sendMapResponse turns a [tailcfg.MapResponse] into a slice of
// [netmap.NodeMutation] via netmap.MutationsFromMapResponse — the same path
// the real controlclient uses on the incremental-fast-path — and dispatches
// it through [LocalBackend.UpdateNetmapDelta]. Going through MapResponse
// rather than constructing NodeMutation values directly keeps the test
// honest about wire-shaped inputs: every mutation kind exercised here is
// reachable by the control server with no test-only constructs.
func (h *driveEndToEndHarness) sendMapResponse(mr *tailcfg.MapResponse) {
h.t.Helper()
muts, ok := netmap.MutationsFromMapResponse(mr, time.Time{})
if !ok {
h.t.Fatalf("MutationsFromMapResponse(%+v) returned !ok", mr)
}
if !h.b.UpdateNetmapDelta(muts) {
h.t.Fatalf("UpdateNetmapDelta(%+v) returned false", muts)
}
}
// readDirNames issues a real WebDAV PROPFIND against the local Taildrive
// root and returns the names of the directory entries the filesystem
// reports as available, sorted for stable comparison.
func (h *driveEndToEndHarness) readDirNames() []string {
h.t.Helper()
infos, err := h.client.ReadDir("/" + h.domain)
if err != nil {
h.t.Fatalf("ReadDir: %v", err)
}
var names []string
for _, fi := range infos {
names = append(names, fi.Name())
}
slices.Sort(names)
return names
}
// waitForDir polls readDirNames until it matches want or the timeout
// elapses. We poll because the gen-bump → next-WebDAV-request rebuild has
// no happens-before synchronization with the caller of UpdateNetmapDelta;
// the rebuild happens lazily on the next inbound request.
func (h *driveEndToEndHarness) waitForDir(want []string) {
h.t.Helper()
slices.Sort(want)
err := tstest.WaitFor(2*time.Second, func() error {
got := h.readDirNames()
if !slices.Equal(got, want) {
return fmt.Errorf("readDirNames = %v, want %v", got, want)
}
return nil
})
if err != nil {
h.t.Fatal(err)
}
}
// TestDriveRemotesEndToEnd exercises the full pipeline from netmap mutation
// through driveGen invalidation, [driveRemoteSource.Remotes] re-evaluation,
// and compositedav child rebuild, validated by a real WebDAV PROPFIND each
// time.
//
// We cover the four ways a peer can enter or leave the drive-capable set:
//
// 1. Peer upsert (PeersChanged → NodeMutationUpsert): adds a brand-new
// sharer peer.
// 2. Peer removal (PeersRemoved → NodeMutationRemove): removes an
// existing sharer.
// 3. Packet-filter change (UpdatePacketFilter): flips an existing peer
// in or out of the drive-capable set without any per-peer mutation.
// 4. Per-peer online toggle (OnlineChange → NodeMutationOnline): doesn't
// change drive-capable membership but does change Available(), so the
// PROPFIND listing must reflect it.
//
// Note: deltas are delivered via [LocalBackend.UpdateNetmapDelta] /
// [LocalBackend.UpdatePacketFilter] directly rather than through a real
// testcontrol stream. Those are the same entry points the controlclient
// calls into after parsing a MapResponse, so the LocalBackend-side
// semantics under test are identical; the wire-level netmap streaming
// itself is covered by [TestNetmapDeltaFastPath] in tstest/largetailnet.
func TestDriveRemotesEndToEnd(t *testing.T) {
h := newDriveEndToEndHarness(t)
peer2 := peerSpec{id: 2, name: "alpha", addr: netip.MustParseAddr("100.64.0.2"), online: true, driveCap: true, peerAPI: true}
peer3 := peerSpec{id: 3, name: "bravo", addr: netip.MustParseAddr("100.64.0.3"), online: true, driveCap: false, peerAPI: true}
// Initial state: peer2 has the sharer cap, peer3 does not. Only
// peer2 should appear in the WebDAV listing.
h.installNetMap([]peerSpec{peer2, peer3})
h.waitForDir([]string{"alpha"})
// 1) Upsert a new sharer peer (peer4) via PeersChanged, then refresh
// the packet filter so PeerHasCap returns true for it. PeerHasCap
// is driven by the filter, so adding the peer alone is not enough.
peer4 := peerSpec{id: 4, name: "charlie", addr: netip.MustParseAddr("100.64.0.4"), online: true, driveCap: true, peerAPI: true}
h.sendMapResponse(&tailcfg.MapResponse{
PeersChanged: []*tailcfg.Node{peer4.node()},
})
if !h.b.UpdatePacketFilter(views.Slice[tailcfg.FilterRule]{}, h.filterMatchesFor([]peerSpec{peer2, peer3, peer4})) {
t.Fatal("UpdatePacketFilter returned false")
}
h.waitForDir([]string{"alpha", "charlie"})
// 2) Remove peer2 via PeersRemoved.
h.sendMapResponse(&tailcfg.MapResponse{
PeersRemoved: []tailcfg.NodeID{peer2.id},
})
if !h.b.UpdatePacketFilter(views.Slice[tailcfg.FilterRule]{}, h.filterMatchesFor([]peerSpec{peer3, peer4})) {
t.Fatal("UpdatePacketFilter returned false")
}
h.waitForDir([]string{"charlie"})
// 3) Flip peer3 into the drive-capable set by changing only the
// packet filter — no per-peer mutation. This exercises the
// UpdatePacketFilter bump path specifically.
peer3.driveCap = true
if !h.b.UpdatePacketFilter(views.Slice[tailcfg.FilterRule]{}, h.filterMatchesFor([]peerSpec{peer3, peer4})) {
t.Fatal("UpdatePacketFilter returned false")
}
h.waitForDir([]string{"bravo", "charlie"})
// 4) Toggle peer3 offline via OnlineChange. The peer is still in the
// drive-capable set so its name remains in the cached Remotes
// slice, but Available() returns false so dirfs filters it out of
// PROPFIND listings.
h.sendMapResponse(&tailcfg.MapResponse{
OnlineChange: map[tailcfg.NodeID]bool{peer3.id: false},
})
h.waitForDir([]string{"charlie"})
// And back online.
h.sendMapResponse(&tailcfg.MapResponse{
OnlineChange: map[tailcfg.NodeID]bool{peer3.id: true},
})
h.waitForDir([]string{"bravo", "charlie"})
}
+58 -4
View File
@@ -416,6 +416,16 @@ type LocalBackend struct {
// notified about.
lastNotifiedDriveShares *views.SliceView[*drive.Share, drive.ShareView]
// driveGen is the generation counter consulted by the [drive.RemoteSource]
// installed on [sys.DriveForLocal]. It is bumped whenever the inputs that
// could change the set of drive-capable remotes might have changed: full
// netmap installs (domain + self caps), netmap delta updates (peer set or
// per-peer addresses), and packet-filter updates (which is where
// [PeerCapability] values actually live). The bump is a single atomic.Add,
// so we do it unconditionally rather than try to detect "did this
// mutation actually matter".
driveGen atomic.Uint64
// lastSuggestedExitNode stores the last suggested exit node suggestion to
// avoid unnecessary churn between multiple equally-good options.
lastSuggestedExitNode tailcfg.StableNodeID
@@ -738,6 +748,12 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
eventbus.SubscribeFunc(ec, b.onHomeDERPUpdate)
mConn.SetNetInfoCallback(b.setNetInfo) // TODO(tailscale/tailscale#17887): move to eventbus
if buildfeatures.HasDrive {
if f, ok := hookInstallDriveRemoteSource.GetOk(); ok {
f(b)
}
}
return b, nil
}
@@ -2455,6 +2471,15 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
muts = b.tkaFilterDeltaMutsLocked(muts)
needsAuthReconfig := netmapDeltaNeedsAuthReconfig(cn, muts)
cn.UpdateNetmapDelta(muts)
if buildfeatures.HasDrive {
// Drive's lazy remotes-source caches its rebuild keyed by this
// generation, so any delta — peer add/remove, address change,
// or otherwise — needs to invalidate that cache. We bump
// unconditionally rather than type-switch over [muts]; the
// atomic add is cheaper than the switch and immune to future
// mutation kinds that might affect drive-cap evaluation.
b.driveGen.Add(1)
}
// In order that we can update the cache, keep track of which nodes are
// updated and removed. The nodeBackend has already applied any deltas, so
@@ -2639,6 +2664,13 @@ func (b *LocalBackend) UpdatePacketFilter(rules views.Slice[tailcfg.FilterRule],
metricUpdatePacketFilter.Add(1)
cn.setPacketFilter(rules, parsed)
b.updateFilterLocked(b.pm.CurrentPrefs())
if buildfeatures.HasDrive {
// The drive sharer cap (and any other PeerCapability) is
// derived from the packet filter, so an updated rule set can
// flip a peer in or out of the drive-capable set without any
// per-peer mutation. Bump so the next WebDAV request rebuilds.
b.driveGen.Add(1)
}
return true
}
@@ -7396,9 +7428,17 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
}
}
if buildfeatures.HasDrive && nm != nil {
if f, ok := hookSetNetMapLockedDrive.GetOk(); ok {
f(b, nm)
if buildfeatures.HasDrive {
// Bump the drive remotes-source generation on every full netmap
// install. The hook is invoked separately below for share-list
// notifications; the bump itself is unconditional because the
// drive feature might not be wired in this build but the atomic
// add is cheap.
b.driveGen.Add(1)
if nm != nil {
if f, ok := hookSetNetMapLockedDrive.GetOk(); ok {
f(b)
}
}
}
@@ -7429,7 +7469,21 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
// HookSetRuntimeMetricsEnabled is an optional hook for the "runtimemetrics" feature.
var HookSetRuntimeMetricsEnabled feature.Hook[func(enabled bool)]
var hookSetNetMapLockedDrive feature.Hook[func(*LocalBackend, *netmap.NetworkMap)]
// hookSetNetMapLockedDrive is invoked on every full netmap install when the
// drive feature is linked in. It does NOT fire on netmap delta updates (peer
// add/remove etc.); those are picked up by the lazy [drive.RemoteSource]
// installed via [hookInstallDriveRemoteSource] plus the per-event bumps to
// [LocalBackend.driveGen]. Its only remaining job is to re-notify IPN bus
// listeners of the current local share list, whose visibility depends on
// self caps that only change on full installs.
var hookSetNetMapLockedDrive feature.Hook[func(*LocalBackend)]
// hookInstallDriveRemoteSource is invoked from [NewLocalBackend] once the
// LocalBackend is far enough along to be used as a source. It registers a
// [drive.RemoteSource] on [sys.DriveForLocal] so the filesystem can pull
// remotes lazily instead of being pushed a fresh list on every netmap
// update.
var hookInstallDriveRemoteSource feature.Hook[func(*LocalBackend)]
// roundTraffic rounds bytes. This is used to preserve user privacy within logs.
func roundTraffic(bytes int64) float64 {