Files
tailscale/ipn/ipnlocal/drive_test.go
Simon Law 00699abdfb tailcfg,tailcfg/{nodecap,selfcap}: split capability constants to their own packages (#20639)
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>
2026-08-07 16:30:35 -07:00

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"})
}