mirror of
https://github.com/tailscale/tailscale.git
synced 2026-09-15 15:29:33 -04:00
Package tailcfg defines the types and constants used by the Tailscale protocol, but since everything is all in one package, it’s difficult to sift through the docs: https://pkg.go.dev/tailscale.com/tailcfg We define and enumerate capabilities as string constants for tailcfg.NodeCapability and tailcfg.PeerCapability. This PR extracts them into their own packages: - tailcfg.CapabilityFileSharing becomes nodecap.FileSharing - tailcfg.NodeAttrOnlyTCP443 becomes nodecap.OnlyTCP443 - tailcfg.PeerCapabilityTaildrive becomes peercap.Taildrive We originally intended for CapabilityFoo to grant an entitlement or permission for Foo, and for NodeAttrBar to configure Bar in the nodeAttrs section of the policy file. However, there was no technical enforcement of this convention, so new capabilities have used the NodeAttr prefix regardless of meaning. Therefore, this PR unifies tailcfg.CapabilityFoo and tailcfg.NodeAttrBar into a single package as nodecap.Foo and nodecap.Bar. Ran `go fix -inline ./...` and committed the changes that replaced uses of the tailcfg aliases with the authoritative ones. Updates #20259 Change-Id: Ieb7e7e6c8247c39faf42fdf15c68cdc7c621c730 Signed-off-by: Simon Law <sfllaw@tailscale.com>
547 lines
17 KiB
Go
547 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/tailcfg/nodecap"
|
|
"tailscale.com/tailcfg/peercap"
|
|
"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[nodecap.Cap]) {
|
|
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(nodecap.TaildriveAccess))
|
|
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: peercap.TaildriveSharer,
|
|
}},
|
|
})
|
|
}
|
|
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(nodecap.TaildriveAccess),
|
|
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"})
|
|
}
|