This applies the same treatment from PR #20162 (netlog) and PR #20171 (wglog) to the local Taildrive filesystem wiring, ending the per-netmap-update O(n) rebuild of the drive remotes list. This moves the O(n peers) taildrive-remote list rebuild from every peer change (which previously happened regardless of whether you were even using taildrive) to instead happen only as needed. That running on every netmap update and was a contributor to the broader quadratic behavior we want to eliminate when a single peer is added or removed. Instead, this introduces drive.RemoteSource, a small interface the Taildrive filesystem pulls from lazily on incoming WebDAV requests, and caches by a generation counter. ipn/ipnlocal installs a driveRemoteSource once at NewLocalBackend time and bumps LocalBackend.driveGen on the three events that can actually flip the drive-capable peer set: full netmap installs (domain + self caps), UpdateNetmapDelta (peer add/remove or per-peer address changes), and updatePacketFilter (since PeerCapability values are derived from the packet filter rules, not from peer.CapMap). The hook itself is kept but narrowed: it no longer takes a *netmap.NetworkMap and its only remaining job is to re-notify IPN bus listeners of the current local shares list on full installs. This is a dependency to removing the netmap.NetworkMap type from upstream callers, like wgengine.Engine in general. (Also add a bunch more tests) Updates #12542 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: I7e3d2f5b4a9c8e1d6f0a3b7c9e2d4f8a1b6c5e9d
545 lines
17 KiB
Go
545 lines
17 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !ts_omit_drive
|
|
|
|
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
|
|
// doesn't panic when the underlying transport returns a nil response with an
|
|
// error.
|
|
//
|
|
// See: https://github.com/tailscale/tailscale/issues/17306
|
|
func TestDriveTransportRoundTrip_NetworkError(t *testing.T) {
|
|
b := newTestLocalBackend(t)
|
|
|
|
testErr := errors.New("network connection failed")
|
|
mockTransport := &mockRoundTripper{
|
|
err: testErr,
|
|
}
|
|
dt := &driveTransport{
|
|
b: b,
|
|
tr: mockTransport,
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "http://100.64.0.1:1234/some/path", nil)
|
|
resp, err := dt.RoundTrip(req)
|
|
if err == nil {
|
|
t.Fatal("got nil error, expected non-nil")
|
|
} else if !errors.Is(err, testErr) {
|
|
t.Errorf("got error %v, expected %v", err, testErr)
|
|
}
|
|
if resp != nil {
|
|
t.Errorf("wanted nil response, got %v", resp)
|
|
}
|
|
}
|
|
|
|
type mockRoundTripper struct {
|
|
err error
|
|
}
|
|
|
|
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"})
|
|
}
|