Просмотр исходного кода

feature/drive: start factoring out Taildrive, add ts_omit_drive build tag

As of this commit (per the issue), the Taildrive code remains where it
was, but in new files that are protected by the new ts_omit_drive
build tag. Future commits will move it.

Updates #17058

Change-Id: Idf0a51db59e41ae8da6ea2b11d238aefc48b219e
Signed-off-by: Brad Fitzpatrick <[email protected]>
Brad Fitzpatrick 5 месяцев назад
Родитель
Сommit
a1dcf12b67

+ 1 - 1
build_dist.sh

@@ -41,7 +41,7 @@ while [ "$#" -gt 1 ]; do
 		fi
 		fi
 		shift
 		shift
 		ldflags="$ldflags -w -s"
 		ldflags="$ldflags -w -s"
-		tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion,ts_omit_ssh,ts_omit_wakeonlan,ts_omit_capture,ts_omit_relayserver,ts_omit_systray,ts_omit_taildrop,ts_omit_tpm,ts_omit_syspolicy,ts_omit_debugeventbus,ts_omit_webclient"
+		tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion,ts_omit_ssh,ts_omit_wakeonlan,ts_omit_capture,ts_omit_relayserver,ts_omit_systray,ts_omit_taildrop,ts_omit_tpm,ts_omit_syspolicy,ts_omit_debugeventbus,ts_omit_webclient,ts_omit_drive"
 		;;
 		;;
 	--box)
 	--box)
 		if [ ! -z "${TAGS:-}" ]; then
 		if [ ! -z "${TAGS:-}" ]; then

+ 2 - 1
cmd/tailscale/cli/cli.go

@@ -210,6 +210,7 @@ func noDupFlagify(c *ffcli.Command) {
 var fileCmd func() *ffcli.Command
 var fileCmd func() *ffcli.Command
 var sysPolicyCmd func() *ffcli.Command
 var sysPolicyCmd func() *ffcli.Command
 var maybeWebCmd func() *ffcli.Command
 var maybeWebCmd func() *ffcli.Command
+var maybeDriveCmd func() *ffcli.Command
 
 
 func newRootCmd() *ffcli.Command {
 func newRootCmd() *ffcli.Command {
 	rootfs := newFlagSet("tailscale")
 	rootfs := newFlagSet("tailscale")
@@ -262,7 +263,7 @@ change in the future.
 			updateCmd,
 			updateCmd,
 			whoisCmd,
 			whoisCmd,
 			debugCmd(),
 			debugCmd(),
-			driveCmd,
+			nilOrCall(maybeDriveCmd),
 			idTokenCmd,
 			idTokenCmd,
 			configureHostCmd(),
 			configureHostCmd(),
 			systrayCmd,
 			systrayCmd,

+ 44 - 36
cmd/tailscale/cli/drive.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 // SPDX-License-Identifier: BSD-3-Clause
 
 
+//go:build !ts_omit_drive
+
 package cli
 package cli
 
 
 import (
 import (
@@ -20,43 +22,49 @@ const (
 	driveListUsage    = "tailscale drive list"
 	driveListUsage    = "tailscale drive list"
 )
 )
 
 
-var driveCmd = &ffcli.Command{
-	Name:      "drive",
-	ShortHelp: "Share a directory with your tailnet",
-	ShortUsage: strings.Join([]string{
-		driveShareUsage,
-		driveRenameUsage,
-		driveUnshareUsage,
-		driveListUsage,
-	}, "\n"),
-	LongHelp:  buildShareLongHelp(),
-	UsageFunc: usageFuncNoDefaultValues,
-	Subcommands: []*ffcli.Command{
-		{
-			Name:       "share",
-			ShortUsage: driveShareUsage,
-			Exec:       runDriveShare,
-			ShortHelp:  "[ALPHA] Create or modify a share",
-		},
-		{
-			Name:       "rename",
-			ShortUsage: driveRenameUsage,
-			ShortHelp:  "[ALPHA] Rename a share",
-			Exec:       runDriveRename,
-		},
-		{
-			Name:       "unshare",
-			ShortUsage: driveUnshareUsage,
-			ShortHelp:  "[ALPHA] Remove a share",
-			Exec:       runDriveUnshare,
-		},
-		{
-			Name:       "list",
-			ShortUsage: driveListUsage,
-			ShortHelp:  "[ALPHA] List current shares",
-			Exec:       runDriveList,
+func init() {
+	maybeDriveCmd = driveCmd
+}
+
+func driveCmd() *ffcli.Command {
+	return &ffcli.Command{
+		Name:      "drive",
+		ShortHelp: "Share a directory with your tailnet",
+		ShortUsage: strings.Join([]string{
+			driveShareUsage,
+			driveRenameUsage,
+			driveUnshareUsage,
+			driveListUsage,
+		}, "\n"),
+		LongHelp:  buildShareLongHelp(),
+		UsageFunc: usageFuncNoDefaultValues,
+		Subcommands: []*ffcli.Command{
+			{
+				Name:       "share",
+				ShortUsage: driveShareUsage,
+				Exec:       runDriveShare,
+				ShortHelp:  "[ALPHA] Create or modify a share",
+			},
+			{
+				Name:       "rename",
+				ShortUsage: driveRenameUsage,
+				ShortHelp:  "[ALPHA] Rename a share",
+				Exec:       runDriveRename,
+			},
+			{
+				Name:       "unshare",
+				ShortUsage: driveUnshareUsage,
+				ShortHelp:  "[ALPHA] Remove a share",
+				Exec:       runDriveUnshare,
+			},
+			{
+				Name:       "list",
+				ShortUsage: driveListUsage,
+				ShortHelp:  "[ALPHA] List current shares",
+				Exec:       runDriveList,
+			},
 		},
 		},
-	},
+	}
 }
 }
 
 
 // runDriveShare is the entry point for the "tailscale drive share" command.
 // runDriveShare is the entry point for the "tailscale drive share" command.

+ 1 - 0
cmd/tailscaled/depaware.txt

@@ -274,6 +274,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
         tailscale.com/feature                                        from tailscale.com/feature/wakeonlan+
         tailscale.com/feature                                        from tailscale.com/feature/wakeonlan+
         tailscale.com/feature/capture                                from tailscale.com/feature/condregister
         tailscale.com/feature/capture                                from tailscale.com/feature/condregister
         tailscale.com/feature/condregister                           from tailscale.com/cmd/tailscaled
         tailscale.com/feature/condregister                           from tailscale.com/cmd/tailscaled
+        tailscale.com/feature/drive                                  from tailscale.com/feature/condregister
         tailscale.com/feature/relayserver                            from tailscale.com/feature/condregister
         tailscale.com/feature/relayserver                            from tailscale.com/feature/condregister
         tailscale.com/feature/syspolicy                              from tailscale.com/feature/condregister+
         tailscale.com/feature/syspolicy                              from tailscale.com/feature/condregister+
         tailscale.com/feature/taildrop                               from tailscale.com/feature/condregister
         tailscale.com/feature/taildrop                               from tailscale.com/feature/condregister

+ 16 - 0
cmd/tailscaled/deps_test.go

@@ -61,3 +61,19 @@ func TestOmitReflectThings(t *testing.T) {
 		},
 		},
 	}.Check(t)
 	}.Check(t)
 }
 }
+
+func TestOmitDrive(t *testing.T) {
+	deptest.DepChecker{
+		GOOS:   "linux",
+		GOARCH: "amd64",
+		Tags:   "ts_omit_drive,ts_include_cli",
+		OnDep: func(dep string) {
+			if strings.Contains(dep, "driveimpl") {
+				t.Errorf("unexpected dep with ts_omit_drive: %q", dep)
+			}
+			if strings.Contains(dep, "webdav") {
+				t.Errorf("unexpected dep with ts_omit_drive: %q", dep)
+			}
+		},
+	}.Check(t)
+}

+ 12 - 33
cmd/tailscaled/tailscaled.go

@@ -33,8 +33,8 @@ import (
 	"tailscale.com/client/local"
 	"tailscale.com/client/local"
 	"tailscale.com/cmd/tailscaled/childproc"
 	"tailscale.com/cmd/tailscaled/childproc"
 	"tailscale.com/control/controlclient"
 	"tailscale.com/control/controlclient"
-	"tailscale.com/drive/driveimpl"
 	"tailscale.com/envknob"
 	"tailscale.com/envknob"
+	"tailscale.com/feature"
 	_ "tailscale.com/feature/condregister"
 	_ "tailscale.com/feature/condregister"
 	"tailscale.com/hostinfo"
 	"tailscale.com/hostinfo"
 	"tailscale.com/ipn"
 	"tailscale.com/ipn"
@@ -153,7 +153,6 @@ var subCommands = map[string]*func([]string) error{
 	"uninstall-system-daemon": &uninstallSystemDaemon,
 	"uninstall-system-daemon": &uninstallSystemDaemon,
 	"debug":                   &debugModeFunc,
 	"debug":                   &debugModeFunc,
 	"be-child":                &beChildFunc,
 	"be-child":                &beChildFunc,
-	"serve-taildrive":         &serveDriveFunc,
 }
 }
 
 
 var beCLI func() // non-nil if CLI is linked in with the "ts_include_cli" build tag
 var beCLI func() // non-nil if CLI is linked in with the "ts_include_cli" build tag
@@ -480,7 +479,9 @@ func run() (err error) {
 		debugMux = newDebugMux()
 		debugMux = newDebugMux()
 	}
 	}
 
 
-	sys.Set(driveimpl.NewFileSystemForRemote(logf))
+	if f, ok := hookSetSysDrive.GetOk(); ok {
+		f(sys, logf)
+	}
 
 
 	if app := envknob.App(); app != "" {
 	if app := envknob.App(); app != "" {
 		hostinfo.SetApp(app)
 		hostinfo.SetApp(app)
@@ -489,6 +490,11 @@ func run() (err error) {
 	return startIPNServer(context.Background(), logf, pol.PublicID, sys)
 	return startIPNServer(context.Background(), logf, pol.PublicID, sys)
 }
 }
 
 
+var (
+	hookSetSysDrive           feature.Hook[func(*tsd.System, logger.Logf)]
+	hookSetWgEnginConfigDrive feature.Hook[func(*wgengine.Config, logger.Logf)]
+)
+
 var sigPipe os.Signal // set by sigpipe.go
 var sigPipe os.Signal // set by sigpipe.go
 
 
 func startIPNServer(ctx context.Context, logf logger.Logf, logID logid.PublicID, sys *tsd.System) error {
 func startIPNServer(ctx context.Context, logf logger.Logf, logID logid.PublicID, sys *tsd.System) error {
@@ -749,7 +755,9 @@ func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack boo
 		SetSubsystem:  sys.Set,
 		SetSubsystem:  sys.Set,
 		ControlKnobs:  sys.ControlKnobs(),
 		ControlKnobs:  sys.ControlKnobs(),
 		EventBus:      sys.Bus.Get(),
 		EventBus:      sys.Bus.Get(),
-		DriveForLocal: driveimpl.NewFileSystemForLocal(logf),
+	}
+	if f, ok := hookSetWgEnginConfigDrive.GetOk(); ok {
+		f(&conf, logf)
 	}
 	}
 
 
 	sys.HealthTracker().SetMetricsRegistry(sys.UserMetricsRegistry())
 	sys.HealthTracker().SetMetricsRegistry(sys.UserMetricsRegistry())
@@ -943,35 +951,6 @@ func beChild(args []string) error {
 	return f(args[1:])
 	return f(args[1:])
 }
 }
 
 
-var serveDriveFunc = serveDrive
-
-// serveDrive serves one or more Taildrives on localhost using the WebDAV
-// protocol. On UNIX and MacOS tailscaled environment, Taildrive spawns child
-// tailscaled processes in serve-taildrive mode in order to access the fliesystem
-// as specific (usually unprivileged) users.
-//
-// serveDrive prints the address on which it's listening to stdout so that the
-// parent process knows where to connect to.
-func serveDrive(args []string) error {
-	if len(args) == 0 {
-		return errors.New("missing shares")
-	}
-	if len(args)%2 != 0 {
-		return errors.New("need <sharename> <path> pairs")
-	}
-	s, err := driveimpl.NewFileServer()
-	if err != nil {
-		return fmt.Errorf("unable to start Taildrive file server: %v", err)
-	}
-	shares := make(map[string]string)
-	for i := 0; i < len(args); i += 2 {
-		shares[args[i]] = args[i+1]
-	}
-	s.SetShares(shares)
-	fmt.Printf("%v\n", s.Addr())
-	return s.Serve()
-}
-
 // dieOnPipeReadErrorOfFD reads from the pipe named by fd and exit the process
 // dieOnPipeReadErrorOfFD reads from the pipe named by fd and exit the process
 // when the pipe becomes readable. We use this in tests as a somewhat more
 // when the pipe becomes readable. We use this in tests as a somewhat more
 // portable mechanism for the Linux PR_SET_PDEATHSIG, which we wish existed on
 // portable mechanism for the Linux PR_SET_PDEATHSIG, which we wish existed on

+ 56 - 0
cmd/tailscaled/tailscaled_drive.go

@@ -0,0 +1,56 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !ts_omit_drive
+
+package main
+
+import (
+	"errors"
+	"fmt"
+
+	"tailscale.com/drive/driveimpl"
+	"tailscale.com/tsd"
+	"tailscale.com/types/logger"
+	"tailscale.com/wgengine"
+)
+
+func init() {
+	subCommands["serve-taildrive"] = &serveDriveFunc
+
+	hookSetSysDrive.Set(func(sys *tsd.System, logf logger.Logf) {
+		sys.Set(driveimpl.NewFileSystemForRemote(logf))
+	})
+	hookSetWgEnginConfigDrive.Set(func(conf *wgengine.Config, logf logger.Logf) {
+		conf.DriveForLocal = driveimpl.NewFileSystemForLocal(logf)
+	})
+}
+
+var serveDriveFunc = serveDrive
+
+// serveDrive serves one or more Taildrives on localhost using the WebDAV
+// protocol. On UNIX and MacOS tailscaled environment, Taildrive spawns child
+// tailscaled processes in serve-taildrive mode in order to access the fliesystem
+// as specific (usually unprivileged) users.
+//
+// serveDrive prints the address on which it's listening to stdout so that the
+// parent process knows where to connect to.
+func serveDrive(args []string) error {
+	if len(args) == 0 {
+		return errors.New("missing shares")
+	}
+	if len(args)%2 != 0 {
+		return errors.New("need <sharename> <path> pairs")
+	}
+	s, err := driveimpl.NewFileServer()
+	if err != nil {
+		return fmt.Errorf("unable to start Taildrive file server: %v", err)
+	}
+	shares := make(map[string]string)
+	for i := 0; i < len(args); i += 2 {
+		shares[args[i]] = args[i+1]
+	}
+	s.SetShares(shares)
+	fmt.Printf("%v\n", s.Addr())
+	return s.Serve()
+}

+ 8 - 0
feature/condregister/maybe_drive.go

@@ -0,0 +1,8 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !ts_omit_drive
+
+package condregister
+
+import _ "tailscale.com/feature/drive"

+ 5 - 0
feature/drive/drive.go

@@ -0,0 +1,5 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Package drive registers the Taildrive (file server) feature.
+package drive

+ 147 - 16
ipn/ipnlocal/drive.go

@@ -1,38 +1,35 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 // SPDX-License-Identifier: BSD-3-Clause
 
 
+//go:build !ts_omit_drive
+
 package ipnlocal
 package ipnlocal
 
 
 import (
 import (
+	"errors"
 	"fmt"
 	"fmt"
+	"io"
+	"net/http"
+	"net/netip"
 	"os"
 	"os"
 	"slices"
 	"slices"
 
 
 	"tailscale.com/drive"
 	"tailscale.com/drive"
 	"tailscale.com/ipn"
 	"tailscale.com/ipn"
 	"tailscale.com/tailcfg"
 	"tailscale.com/tailcfg"
+	"tailscale.com/types/logger"
 	"tailscale.com/types/netmap"
 	"tailscale.com/types/netmap"
 	"tailscale.com/types/views"
 	"tailscale.com/types/views"
+	"tailscale.com/util/httpm"
 )
 )
 
 
-const (
-	// DriveLocalPort is the port on which the Taildrive listens for location
-	// connections on quad 100.
-	DriveLocalPort = 8080
-)
-
-// DriveSharingEnabled reports whether sharing to remote nodes via Taildrive is
-// enabled. This is currently based on checking for the drive:share node
-// attribute.
-func (b *LocalBackend) DriveSharingEnabled() bool {
-	return b.currentNode().SelfHasCap(tailcfg.NodeAttrsTaildriveShare)
+func init() {
+	hookSetNetMapLockedDrive.Set(setNetMapLockedDrive)
 }
 }
 
 
-// DriveAccessEnabled reports whether accessing Taildrive shares on remote nodes
-// is enabled. This is currently based on checking for the drive:access node
-// attribute.
-func (b *LocalBackend) DriveAccessEnabled() bool {
-	return b.currentNode().SelfHasCap(tailcfg.NodeAttrsTaildriveAccess)
+func setNetMapLockedDrive(b *LocalBackend, nm *netmap.NetworkMap) {
+	b.updateDrivePeersLocked(nm)
+	b.driveNotifyCurrentSharesLocked()
 }
 }
 
 
 // DriveSetServerAddr tells Taildrive to use the given address for connecting
 // DriveSetServerAddr tells Taildrive to use the given address for connecting
@@ -363,3 +360,137 @@ func (b *LocalBackend) driveRemotesFromPeers(nm *netmap.NetworkMap) []*drive.Rem
 	}
 	}
 	return driveRemotes
 	return driveRemotes
 }
 }
+
+// responseBodyWrapper wraps an io.ReadCloser and stores
+// the number of bytesRead.
+type responseBodyWrapper struct {
+	io.ReadCloser
+	logVerbose    bool
+	bytesRx       int64
+	bytesTx       int64
+	log           logger.Logf
+	method        string
+	statusCode    int
+	contentType   string
+	fileExtension string
+	shareNodeKey  string
+	selfNodeKey   string
+	contentLength int64
+}
+
+// logAccess logs the taildrive: access: log line. If the logger is nil,
+// the log will not be written.
+func (rbw *responseBodyWrapper) logAccess(err string) {
+	if rbw.log == nil {
+		return
+	}
+
+	// Some operating systems create and copy lots of 0 length hidden files for
+	// tracking various states. Omit these to keep logs from being too verbose.
+	if rbw.logVerbose || rbw.contentLength > 0 {
+		levelPrefix := ""
+		if rbw.logVerbose {
+			levelPrefix = "[v1] "
+		}
+		rbw.log(
+			"%staildrive: access: %s from %s to %s: status-code=%d ext=%q content-type=%q content-length=%.f tx=%.f rx=%.f err=%q",
+			levelPrefix,
+			rbw.method,
+			rbw.selfNodeKey,
+			rbw.shareNodeKey,
+			rbw.statusCode,
+			rbw.fileExtension,
+			rbw.contentType,
+			roundTraffic(rbw.contentLength),
+			roundTraffic(rbw.bytesTx), roundTraffic(rbw.bytesRx), err)
+	}
+}
+
+// Read implements the io.Reader interface.
+func (rbw *responseBodyWrapper) Read(b []byte) (int, error) {
+	n, err := rbw.ReadCloser.Read(b)
+	rbw.bytesRx += int64(n)
+	if err != nil && !errors.Is(err, io.EOF) {
+		rbw.logAccess(err.Error())
+	}
+
+	return n, err
+}
+
+// Close implements the io.Close interface.
+func (rbw *responseBodyWrapper) Close() error {
+	err := rbw.ReadCloser.Close()
+	var errStr string
+	if err != nil {
+		errStr = err.Error()
+	}
+	rbw.logAccess(errStr)
+
+	return err
+}
+
+// driveTransport is an http.RoundTripper that wraps
+// b.Dialer().PeerAPITransport() with metrics tracking.
+type driveTransport struct {
+	b  *LocalBackend
+	tr *http.Transport
+}
+
+func (b *LocalBackend) newDriveTransport() *driveTransport {
+	return &driveTransport{
+		b:  b,
+		tr: b.Dialer().PeerAPITransport(),
+	}
+}
+
+func (dt *driveTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
+	// Some WebDAV clients include origin and refer headers, which peerapi does
+	// not like. Remove them.
+	req.Header.Del("origin")
+	req.Header.Del("referer")
+
+	bw := &requestBodyWrapper{}
+	if req.Body != nil {
+		bw.ReadCloser = req.Body
+		req.Body = bw
+	}
+
+	defer func() {
+		contentType := "unknown"
+		if ct := req.Header.Get("Content-Type"); ct != "" {
+			contentType = ct
+		}
+
+		dt.b.mu.Lock()
+		selfNodeKey := dt.b.currentNode().Self().Key().ShortString()
+		dt.b.mu.Unlock()
+		n, _, ok := dt.b.WhoIs("tcp", netip.MustParseAddrPort(req.URL.Host))
+		shareNodeKey := "unknown"
+		if ok {
+			shareNodeKey = string(n.Key().ShortString())
+		}
+
+		rbw := responseBodyWrapper{
+			log:           dt.b.logf,
+			logVerbose:    req.Method != httpm.GET && req.Method != httpm.PUT, // other requests like PROPFIND are quite chatty, so we log those at verbose level
+			method:        req.Method,
+			bytesTx:       int64(bw.bytesRead),
+			selfNodeKey:   selfNodeKey,
+			shareNodeKey:  shareNodeKey,
+			contentType:   contentType,
+			contentLength: resp.ContentLength,
+			fileExtension: parseDriveFileExtensionForLog(req.URL.Path),
+			statusCode:    resp.StatusCode,
+			ReadCloser:    resp.Body,
+		}
+
+		if resp.StatusCode >= 400 {
+			// in case of error response, just log immediately
+			rbw.logAccess("")
+		} else {
+			resp.Body = &rbw
+		}
+	}()
+
+	return dt.tr.RoundTrip(req)
+}

+ 30 - 0
ipn/ipnlocal/drive_tomove.go

@@ -0,0 +1,30 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// This is the Taildrive stuff that should ideally be registered in init only when
+// the ts_omit_drive is not set, but for transition reasons is currently (2025-09-08)
+// always defined, as we work to pull it out of LocalBackend.
+
+package ipnlocal
+
+import "tailscale.com/tailcfg"
+
+const (
+	// DriveLocalPort is the port on which the Taildrive listens for location
+	// connections on quad 100.
+	DriveLocalPort = 8080
+)
+
+// DriveSharingEnabled reports whether sharing to remote nodes via Taildrive is
+// enabled. This is currently based on checking for the drive:share node
+// attribute.
+func (b *LocalBackend) DriveSharingEnabled() bool {
+	return b.currentNode().SelfHasCap(tailcfg.NodeAttrsTaildriveShare)
+}
+
+// DriveAccessEnabled reports whether accessing Taildrive shares on remote nodes
+// is enabled. This is currently based on checking for the drive:access node
+// attribute.
+func (b *LocalBackend) DriveAccessEnabled() bool {
+	return b.currentNode().SelfHasCap(tailcfg.NodeAttrsTaildriveAccess)
+}

+ 4 - 135
ipn/ipnlocal/local.go

@@ -52,6 +52,7 @@ import (
 	"tailscale.com/drive"
 	"tailscale.com/drive"
 	"tailscale.com/envknob"
 	"tailscale.com/envknob"
 	"tailscale.com/envknob/featureknob"
 	"tailscale.com/envknob/featureknob"
+	"tailscale.com/feature"
 	"tailscale.com/health"
 	"tailscale.com/health"
 	"tailscale.com/health/healthmsg"
 	"tailscale.com/health/healthmsg"
 	"tailscale.com/hostinfo"
 	"tailscale.com/hostinfo"
@@ -100,7 +101,6 @@ import (
 	"tailscale.com/util/deephash"
 	"tailscale.com/util/deephash"
 	"tailscale.com/util/dnsname"
 	"tailscale.com/util/dnsname"
 	"tailscale.com/util/goroutines"
 	"tailscale.com/util/goroutines"
-	"tailscale.com/util/httpm"
 	"tailscale.com/util/mak"
 	"tailscale.com/util/mak"
 	"tailscale.com/util/multierr"
 	"tailscale.com/util/multierr"
 	"tailscale.com/util/osuser"
 	"tailscale.com/util/osuser"
@@ -6326,143 +6326,12 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
 		b.metrics.approvedRoutes.Set(approved)
 		b.metrics.approvedRoutes.Set(approved)
 	}
 	}
 
 
-	b.updateDrivePeersLocked(nm)
-	b.driveNotifyCurrentSharesLocked()
-}
-
-// responseBodyWrapper wraps an io.ReadCloser and stores
-// the number of bytesRead.
-type responseBodyWrapper struct {
-	io.ReadCloser
-	logVerbose    bool
-	bytesRx       int64
-	bytesTx       int64
-	log           logger.Logf
-	method        string
-	statusCode    int
-	contentType   string
-	fileExtension string
-	shareNodeKey  string
-	selfNodeKey   string
-	contentLength int64
-}
-
-// logAccess logs the taildrive: access: log line. If the logger is nil,
-// the log will not be written.
-func (rbw *responseBodyWrapper) logAccess(err string) {
-	if rbw.log == nil {
-		return
-	}
-
-	// Some operating systems create and copy lots of 0 length hidden files for
-	// tracking various states. Omit these to keep logs from being too verbose.
-	if rbw.logVerbose || rbw.contentLength > 0 {
-		levelPrefix := ""
-		if rbw.logVerbose {
-			levelPrefix = "[v1] "
-		}
-		rbw.log(
-			"%staildrive: access: %s from %s to %s: status-code=%d ext=%q content-type=%q content-length=%.f tx=%.f rx=%.f err=%q",
-			levelPrefix,
-			rbw.method,
-			rbw.selfNodeKey,
-			rbw.shareNodeKey,
-			rbw.statusCode,
-			rbw.fileExtension,
-			rbw.contentType,
-			roundTraffic(rbw.contentLength),
-			roundTraffic(rbw.bytesTx), roundTraffic(rbw.bytesRx), err)
-	}
-}
-
-// Read implements the io.Reader interface.
-func (rbw *responseBodyWrapper) Read(b []byte) (int, error) {
-	n, err := rbw.ReadCloser.Read(b)
-	rbw.bytesRx += int64(n)
-	if err != nil && !errors.Is(err, io.EOF) {
-		rbw.logAccess(err.Error())
-	}
-
-	return n, err
-}
-
-// Close implements the io.Close interface.
-func (rbw *responseBodyWrapper) Close() error {
-	err := rbw.ReadCloser.Close()
-	var errStr string
-	if err != nil {
-		errStr = err.Error()
+	if f, ok := hookSetNetMapLockedDrive.GetOk(); ok {
+		f(b, nm)
 	}
 	}
-	rbw.logAccess(errStr)
-
-	return err
-}
-
-// driveTransport is an http.RoundTripper that wraps
-// b.Dialer().PeerAPITransport() with metrics tracking.
-type driveTransport struct {
-	b  *LocalBackend
-	tr *http.Transport
 }
 }
 
 
-func (b *LocalBackend) newDriveTransport() *driveTransport {
-	return &driveTransport{
-		b:  b,
-		tr: b.Dialer().PeerAPITransport(),
-	}
-}
-
-func (dt *driveTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
-	// Some WebDAV clients include origin and refer headers, which peerapi does
-	// not like. Remove them.
-	req.Header.Del("origin")
-	req.Header.Del("referer")
-
-	bw := &requestBodyWrapper{}
-	if req.Body != nil {
-		bw.ReadCloser = req.Body
-		req.Body = bw
-	}
-
-	defer func() {
-		contentType := "unknown"
-		if ct := req.Header.Get("Content-Type"); ct != "" {
-			contentType = ct
-		}
-
-		dt.b.mu.Lock()
-		selfNodeKey := dt.b.currentNode().Self().Key().ShortString()
-		dt.b.mu.Unlock()
-		n, _, ok := dt.b.WhoIs("tcp", netip.MustParseAddrPort(req.URL.Host))
-		shareNodeKey := "unknown"
-		if ok {
-			shareNodeKey = string(n.Key().ShortString())
-		}
-
-		rbw := responseBodyWrapper{
-			log:           dt.b.logf,
-			logVerbose:    req.Method != httpm.GET && req.Method != httpm.PUT, // other requests like PROPFIND are quite chatty, so we log those at verbose level
-			method:        req.Method,
-			bytesTx:       int64(bw.bytesRead),
-			selfNodeKey:   selfNodeKey,
-			shareNodeKey:  shareNodeKey,
-			contentType:   contentType,
-			contentLength: resp.ContentLength,
-			fileExtension: parseDriveFileExtensionForLog(req.URL.Path),
-			statusCode:    resp.StatusCode,
-			ReadCloser:    resp.Body,
-		}
-
-		if resp.StatusCode >= 400 {
-			// in case of error response, just log immediately
-			rbw.logAccess("")
-		} else {
-			resp.Body = &rbw
-		}
-	}()
-
-	return dt.tr.RoundTrip(req)
-}
+var hookSetNetMapLockedDrive feature.Hook[func(*LocalBackend, *netmap.NetworkMap)]
 
 
 // roundTraffic rounds bytes. This is used to preserve user privacy within logs.
 // roundTraffic rounds bytes. This is used to preserve user privacy within logs.
 func roundTraffic(bytes int64) float64 {
 func roundTraffic(bytes int64) float64 {

+ 0 - 95
ipn/ipnlocal/peerapi.go

@@ -16,7 +16,6 @@ import (
 	"net/http"
 	"net/http"
 	"net/netip"
 	"net/netip"
 	"os"
 	"os"
-	"path/filepath"
 	"runtime"
 	"runtime"
 	"slices"
 	"slices"
 	"strconv"
 	"strconv"
@@ -26,7 +25,6 @@ import (
 
 
 	"golang.org/x/net/dns/dnsmessage"
 	"golang.org/x/net/dns/dnsmessage"
 	"golang.org/x/net/http/httpguts"
 	"golang.org/x/net/http/httpguts"
-	"tailscale.com/drive"
 	"tailscale.com/envknob"
 	"tailscale.com/envknob"
 	"tailscale.com/health"
 	"tailscale.com/health"
 	"tailscale.com/hostinfo"
 	"tailscale.com/hostinfo"
@@ -39,14 +37,9 @@ import (
 	"tailscale.com/types/netmap"
 	"tailscale.com/types/netmap"
 	"tailscale.com/types/views"
 	"tailscale.com/types/views"
 	"tailscale.com/util/clientmetric"
 	"tailscale.com/util/clientmetric"
-	"tailscale.com/util/httpm"
 	"tailscale.com/wgengine/filter"
 	"tailscale.com/wgengine/filter"
 )
 )
 
 
-const (
-	taildrivePrefix = "/v0/drive"
-)
-
 var initListenConfig func(*net.ListenConfig, netip.Addr, *netmon.State, string) error
 var initListenConfig func(*net.ListenConfig, netip.Addr, *netmon.State, string) error
 
 
 // addH2C is non-nil on platforms where we want to add H2C
 // addH2C is non-nil on platforms where we want to add H2C
@@ -369,10 +362,6 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		h.handleDNSQuery(w, r)
 		h.handleDNSQuery(w, r)
 		return
 		return
 	}
 	}
-	if strings.HasPrefix(r.URL.Path, taildrivePrefix) {
-		h.handleServeDrive(w, r)
-		return
-	}
 	switch r.URL.Path {
 	switch r.URL.Path {
 	case "/v0/goroutines":
 	case "/v0/goroutines":
 		h.handleServeGoroutines(w, r)
 		h.handleServeGoroutines(w, r)
@@ -1018,90 +1007,6 @@ func (rbw *requestBodyWrapper) Read(b []byte) (int, error) {
 	return n, err
 	return n, err
 }
 }
 
 
-func (h *peerAPIHandler) handleServeDrive(w http.ResponseWriter, r *http.Request) {
-	h.logfv1("taildrive: got %s request from %s", r.Method, h.peerNode.Key().ShortString())
-	if !h.ps.b.DriveSharingEnabled() {
-		h.logf("taildrive: not enabled")
-		http.Error(w, "taildrive not enabled", http.StatusNotFound)
-		return
-	}
-
-	capsMap := h.PeerCaps()
-	driveCaps, ok := capsMap[tailcfg.PeerCapabilityTaildrive]
-	if !ok {
-		h.logf("taildrive: not permitted")
-		http.Error(w, "taildrive not permitted", http.StatusForbidden)
-		return
-	}
-
-	rawPerms := make([][]byte, 0, len(driveCaps))
-	for _, cap := range driveCaps {
-		rawPerms = append(rawPerms, []byte(cap))
-	}
-
-	p, err := drive.ParsePermissions(rawPerms)
-	if err != nil {
-		h.logf("taildrive: error parsing permissions: %v", err)
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	fs, ok := h.ps.b.sys.DriveForRemote.GetOK()
-	if !ok {
-		h.logf("taildrive: not supported on platform")
-		http.Error(w, "taildrive not supported on platform", http.StatusNotFound)
-		return
-	}
-	wr := &httpResponseWrapper{
-		ResponseWriter: w,
-	}
-	bw := &requestBodyWrapper{
-		ReadCloser: r.Body,
-	}
-	r.Body = bw
-
-	defer func() {
-		switch wr.statusCode {
-		case 304:
-			// 304s are particularly chatty so skip logging.
-		default:
-			log := h.logf
-			if r.Method != httpm.PUT && r.Method != httpm.GET {
-				log = h.logfv1
-			}
-			contentType := "unknown"
-			if ct := wr.Header().Get("Content-Type"); ct != "" {
-				contentType = ct
-			}
-
-			log("taildrive: share: %s from %s to %s: status-code=%d ext=%q content-type=%q tx=%.f rx=%.f", r.Method, h.peerNode.Key().ShortString(), h.selfNode.Key().ShortString(), wr.statusCode, parseDriveFileExtensionForLog(r.URL.Path), contentType, roundTraffic(wr.contentLength), roundTraffic(bw.bytesRead))
-		}
-	}()
-
-	r.URL.Path = strings.TrimPrefix(r.URL.Path, taildrivePrefix)
-	fs.ServeHTTPWithPerms(p, wr, r)
-}
-
-// parseDriveFileExtensionForLog parses the file extension, if available.
-// If a file extension is not present or parsable, the file extension is
-// set to "unknown". If the file extension contains a double quote, it is
-// replaced with "removed".
-// All whitespace is removed from a parsed file extension.
-// File extensions including the leading ., e.g. ".gif".
-func parseDriveFileExtensionForLog(path string) string {
-	fileExt := "unknown"
-	if fe := filepath.Ext(path); fe != "" {
-		if strings.Contains(fe, "\"") {
-			// Do not log include file extensions with quotes within them.
-			return "removed"
-		}
-		// Remove white space from user defined inputs.
-		fileExt = strings.ReplaceAll(fe, " ", "")
-	}
-
-	return fileExt
-}
-
 // peerAPIURL returns an HTTP URL for the peer's peerapi service,
 // peerAPIURL returns an HTTP URL for the peer's peerapi service,
 // without a trailing slash.
 // without a trailing slash.
 //
 //

+ 110 - 0
ipn/ipnlocal/peerapi_drive.go

@@ -0,0 +1,110 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !ts_omit_drive
+
+package ipnlocal
+
+import (
+	"net/http"
+	"path/filepath"
+	"strings"
+
+	"tailscale.com/drive"
+	"tailscale.com/tailcfg"
+	"tailscale.com/util/httpm"
+)
+
+const (
+	taildrivePrefix = "/v0/drive"
+)
+
+func init() {
+	peerAPIHandlerPrefixes[taildrivePrefix] = handleServeDrive
+}
+
+func handleServeDrive(hi PeerAPIHandler, w http.ResponseWriter, r *http.Request) {
+	h := hi.(*peerAPIHandler)
+
+	h.logfv1("taildrive: got %s request from %s", r.Method, h.peerNode.Key().ShortString())
+	if !h.ps.b.DriveSharingEnabled() {
+		h.logf("taildrive: not enabled")
+		http.Error(w, "taildrive not enabled", http.StatusNotFound)
+		return
+	}
+
+	capsMap := h.PeerCaps()
+	driveCaps, ok := capsMap[tailcfg.PeerCapabilityTaildrive]
+	if !ok {
+		h.logf("taildrive: not permitted")
+		http.Error(w, "taildrive not permitted", http.StatusForbidden)
+		return
+	}
+
+	rawPerms := make([][]byte, 0, len(driveCaps))
+	for _, cap := range driveCaps {
+		rawPerms = append(rawPerms, []byte(cap))
+	}
+
+	p, err := drive.ParsePermissions(rawPerms)
+	if err != nil {
+		h.logf("taildrive: error parsing permissions: %v", err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	fs, ok := h.ps.b.sys.DriveForRemote.GetOK()
+	if !ok {
+		h.logf("taildrive: not supported on platform")
+		http.Error(w, "taildrive not supported on platform", http.StatusNotFound)
+		return
+	}
+	wr := &httpResponseWrapper{
+		ResponseWriter: w,
+	}
+	bw := &requestBodyWrapper{
+		ReadCloser: r.Body,
+	}
+	r.Body = bw
+
+	defer func() {
+		switch wr.statusCode {
+		case 304:
+			// 304s are particularly chatty so skip logging.
+		default:
+			log := h.logf
+			if r.Method != httpm.PUT && r.Method != httpm.GET {
+				log = h.logfv1
+			}
+			contentType := "unknown"
+			if ct := wr.Header().Get("Content-Type"); ct != "" {
+				contentType = ct
+			}
+
+			log("taildrive: share: %s from %s to %s: status-code=%d ext=%q content-type=%q tx=%.f rx=%.f", r.Method, h.peerNode.Key().ShortString(), h.selfNode.Key().ShortString(), wr.statusCode, parseDriveFileExtensionForLog(r.URL.Path), contentType, roundTraffic(wr.contentLength), roundTraffic(bw.bytesRead))
+		}
+	}()
+
+	r.URL.Path = strings.TrimPrefix(r.URL.Path, taildrivePrefix)
+	fs.ServeHTTPWithPerms(p, wr, r)
+}
+
+// parseDriveFileExtensionForLog parses the file extension, if available.
+// If a file extension is not present or parsable, the file extension is
+// set to "unknown". If the file extension contains a double quote, it is
+// replaced with "removed".
+// All whitespace is removed from a parsed file extension.
+// File extensions including the leading ., e.g. ".gif".
+func parseDriveFileExtensionForLog(path string) string {
+	fileExt := "unknown"
+	if fe := filepath.Ext(path); fe != "" {
+		if strings.Contains(fe, "\"") {
+			// Do not log include file extensions with quotes within them.
+			return "removed"
+		}
+		// Remove white space from user defined inputs.
+		fileExt = strings.ReplaceAll(fe, " ", "")
+	}
+
+	return fileExt
+}

+ 0 - 123
ipn/localapi/localapi.go

@@ -18,8 +18,6 @@ import (
 	"net/http"
 	"net/http"
 	"net/netip"
 	"net/netip"
 	"net/url"
 	"net/url"
-	"os"
-	"path"
 	"reflect"
 	"reflect"
 	"runtime"
 	"runtime"
 	"slices"
 	"slices"
@@ -31,7 +29,6 @@ import (
 	"golang.org/x/net/dns/dnsmessage"
 	"golang.org/x/net/dns/dnsmessage"
 	"tailscale.com/client/tailscale/apitype"
 	"tailscale.com/client/tailscale/apitype"
 	"tailscale.com/clientupdate"
 	"tailscale.com/clientupdate"
-	"tailscale.com/drive"
 	"tailscale.com/envknob"
 	"tailscale.com/envknob"
 	"tailscale.com/health/healthmsg"
 	"tailscale.com/health/healthmsg"
 	"tailscale.com/hostinfo"
 	"tailscale.com/hostinfo"
@@ -104,8 +101,6 @@ var handler = map[string]LocalAPIHandler{
 	"disconnect-control":           (*Handler).disconnectControl,
 	"disconnect-control":           (*Handler).disconnectControl,
 	"dns-osconfig":                 (*Handler).serveDNSOSConfig,
 	"dns-osconfig":                 (*Handler).serveDNSOSConfig,
 	"dns-query":                    (*Handler).serveDNSQuery,
 	"dns-query":                    (*Handler).serveDNSQuery,
-	"drive/fileserver-address":     (*Handler).serveDriveServerAddr,
-	"drive/shares":                 (*Handler).serveShares,
 	"goroutines":                   (*Handler).serveGoroutines,
 	"goroutines":                   (*Handler).serveGoroutines,
 	"handle-push-message":          (*Handler).serveHandlePushMessage,
 	"handle-push-message":          (*Handler).serveHandlePushMessage,
 	"id-token":                     (*Handler).serveIDToken,
 	"id-token":                     (*Handler).serveIDToken,
@@ -2661,124 +2656,6 @@ func (h *Handler) serveDNSQuery(w http.ResponseWriter, r *http.Request) {
 	})
 	})
 }
 }
 
 
-// serveDriveServerAddr handles updates of the Taildrive file server address.
-func (h *Handler) serveDriveServerAddr(w http.ResponseWriter, r *http.Request) {
-	if r.Method != httpm.PUT {
-		http.Error(w, "only PUT allowed", http.StatusMethodNotAllowed)
-		return
-	}
-
-	b, err := io.ReadAll(r.Body)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusBadRequest)
-		return
-	}
-
-	h.b.DriveSetServerAddr(string(b))
-	w.WriteHeader(http.StatusCreated)
-}
-
-// serveShares handles the management of Taildrive shares.
-//
-// PUT - adds or updates an existing share
-// DELETE - removes a share
-// GET - gets a list of all shares, sorted by name
-// POST - renames an existing share
-func (h *Handler) serveShares(w http.ResponseWriter, r *http.Request) {
-	if !h.b.DriveSharingEnabled() {
-		http.Error(w, `taildrive sharing not enabled, please add the attribute "drive:share" to this node in your ACLs' "nodeAttrs" section`, http.StatusForbidden)
-		return
-	}
-	switch r.Method {
-	case httpm.PUT:
-		var share drive.Share
-		err := json.NewDecoder(r.Body).Decode(&share)
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		share.Path = path.Clean(share.Path)
-		fi, err := os.Stat(share.Path)
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		if !fi.IsDir() {
-			http.Error(w, "not a directory", http.StatusBadRequest)
-			return
-		}
-		if drive.AllowShareAs() {
-			// share as the connected user
-			username, err := h.Actor.Username()
-			if err != nil {
-				http.Error(w, err.Error(), http.StatusInternalServerError)
-				return
-			}
-			share.As = username
-		}
-		err = h.b.DriveSetShare(&share)
-		if err != nil {
-			if errors.Is(err, drive.ErrInvalidShareName) {
-				http.Error(w, "invalid share name", http.StatusBadRequest)
-				return
-			}
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-			return
-		}
-		w.WriteHeader(http.StatusCreated)
-	case httpm.DELETE:
-		b, err := io.ReadAll(r.Body)
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		err = h.b.DriveRemoveShare(string(b))
-		if err != nil {
-			if os.IsNotExist(err) {
-				http.Error(w, "share not found", http.StatusNotFound)
-				return
-			}
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-			return
-		}
-		w.WriteHeader(http.StatusNoContent)
-	case httpm.POST:
-		var names [2]string
-		err := json.NewDecoder(r.Body).Decode(&names)
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		err = h.b.DriveRenameShare(names[0], names[1])
-		if err != nil {
-			if os.IsNotExist(err) {
-				http.Error(w, "share not found", http.StatusNotFound)
-				return
-			}
-			if os.IsExist(err) {
-				http.Error(w, "share name already used", http.StatusBadRequest)
-				return
-			}
-			if errors.Is(err, drive.ErrInvalidShareName) {
-				http.Error(w, "invalid share name", http.StatusBadRequest)
-				return
-			}
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-			return
-		}
-		w.WriteHeader(http.StatusNoContent)
-	case httpm.GET:
-		shares := h.b.DriveGetShares()
-		err := json.NewEncoder(w).Encode(shares)
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-			return
-		}
-	default:
-		http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
-	}
-}
-
 // serveSuggestExitNode serves a POST endpoint for returning a suggested exit node.
 // serveSuggestExitNode serves a POST endpoint for returning a suggested exit node.
 func (h *Handler) serveSuggestExitNode(w http.ResponseWriter, r *http.Request) {
 func (h *Handler) serveSuggestExitNode(w http.ResponseWriter, r *http.Request) {
 	if r.Method != httpm.GET {
 	if r.Method != httpm.GET {

+ 141 - 0
ipn/localapi/localapi_drive.go

@@ -0,0 +1,141 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !ts_omit_drive
+
+package localapi
+
+import (
+	"encoding/json"
+	"errors"
+	"io"
+	"net/http"
+	"os"
+	"path"
+
+	"tailscale.com/drive"
+	"tailscale.com/util/httpm"
+)
+
+func init() {
+	Register("drive/fileserver-address", (*Handler).serveDriveServerAddr)
+	Register("drive/shares", (*Handler).serveShares)
+}
+
+// serveDriveServerAddr handles updates of the Taildrive file server address.
+func (h *Handler) serveDriveServerAddr(w http.ResponseWriter, r *http.Request) {
+	if r.Method != httpm.PUT {
+		http.Error(w, "only PUT allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	b, err := io.ReadAll(r.Body)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	h.b.DriveSetServerAddr(string(b))
+	w.WriteHeader(http.StatusCreated)
+}
+
+// serveShares handles the management of Taildrive shares.
+//
+// PUT - adds or updates an existing share
+// DELETE - removes a share
+// GET - gets a list of all shares, sorted by name
+// POST - renames an existing share
+func (h *Handler) serveShares(w http.ResponseWriter, r *http.Request) {
+	if !h.b.DriveSharingEnabled() {
+		http.Error(w, `taildrive sharing not enabled, please add the attribute "drive:share" to this node in your ACLs' "nodeAttrs" section`, http.StatusForbidden)
+		return
+	}
+	switch r.Method {
+	case httpm.PUT:
+		var share drive.Share
+		err := json.NewDecoder(r.Body).Decode(&share)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		share.Path = path.Clean(share.Path)
+		fi, err := os.Stat(share.Path)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		if !fi.IsDir() {
+			http.Error(w, "not a directory", http.StatusBadRequest)
+			return
+		}
+		if drive.AllowShareAs() {
+			// share as the connected user
+			username, err := h.Actor.Username()
+			if err != nil {
+				http.Error(w, err.Error(), http.StatusInternalServerError)
+				return
+			}
+			share.As = username
+		}
+		err = h.b.DriveSetShare(&share)
+		if err != nil {
+			if errors.Is(err, drive.ErrInvalidShareName) {
+				http.Error(w, "invalid share name", http.StatusBadRequest)
+				return
+			}
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		w.WriteHeader(http.StatusCreated)
+	case httpm.DELETE:
+		b, err := io.ReadAll(r.Body)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		err = h.b.DriveRemoveShare(string(b))
+		if err != nil {
+			if os.IsNotExist(err) {
+				http.Error(w, "share not found", http.StatusNotFound)
+				return
+			}
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		w.WriteHeader(http.StatusNoContent)
+	case httpm.POST:
+		var names [2]string
+		err := json.NewDecoder(r.Body).Decode(&names)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		err = h.b.DriveRenameShare(names[0], names[1])
+		if err != nil {
+			if os.IsNotExist(err) {
+				http.Error(w, "share not found", http.StatusNotFound)
+				return
+			}
+			if os.IsExist(err) {
+				http.Error(w, "share name already used", http.StatusBadRequest)
+				return
+			}
+			if errors.Is(err, drive.ErrInvalidShareName) {
+				http.Error(w, "invalid share name", http.StatusBadRequest)
+				return
+			}
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		w.WriteHeader(http.StatusNoContent)
+	case httpm.GET:
+		shares := h.b.DriveGetShares()
+		err := json.NewEncoder(w).Encode(shares)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	default:
+		http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
+	}
+}

+ 1 - 0
tstest/integration/tailscaled_deps_test_darwin.go

@@ -17,6 +17,7 @@ import (
 	_ "tailscale.com/derp/derphttp"
 	_ "tailscale.com/derp/derphttp"
 	_ "tailscale.com/drive/driveimpl"
 	_ "tailscale.com/drive/driveimpl"
 	_ "tailscale.com/envknob"
 	_ "tailscale.com/envknob"
+	_ "tailscale.com/feature"
 	_ "tailscale.com/feature/condregister"
 	_ "tailscale.com/feature/condregister"
 	_ "tailscale.com/health"
 	_ "tailscale.com/health"
 	_ "tailscale.com/hostinfo"
 	_ "tailscale.com/hostinfo"

+ 1 - 0
tstest/integration/tailscaled_deps_test_freebsd.go

@@ -17,6 +17,7 @@ import (
 	_ "tailscale.com/derp/derphttp"
 	_ "tailscale.com/derp/derphttp"
 	_ "tailscale.com/drive/driveimpl"
 	_ "tailscale.com/drive/driveimpl"
 	_ "tailscale.com/envknob"
 	_ "tailscale.com/envknob"
+	_ "tailscale.com/feature"
 	_ "tailscale.com/feature/condregister"
 	_ "tailscale.com/feature/condregister"
 	_ "tailscale.com/health"
 	_ "tailscale.com/health"
 	_ "tailscale.com/hostinfo"
 	_ "tailscale.com/hostinfo"

+ 1 - 0
tstest/integration/tailscaled_deps_test_linux.go

@@ -17,6 +17,7 @@ import (
 	_ "tailscale.com/derp/derphttp"
 	_ "tailscale.com/derp/derphttp"
 	_ "tailscale.com/drive/driveimpl"
 	_ "tailscale.com/drive/driveimpl"
 	_ "tailscale.com/envknob"
 	_ "tailscale.com/envknob"
+	_ "tailscale.com/feature"
 	_ "tailscale.com/feature/condregister"
 	_ "tailscale.com/feature/condregister"
 	_ "tailscale.com/health"
 	_ "tailscale.com/health"
 	_ "tailscale.com/hostinfo"
 	_ "tailscale.com/hostinfo"

+ 1 - 0
tstest/integration/tailscaled_deps_test_openbsd.go

@@ -17,6 +17,7 @@ import (
 	_ "tailscale.com/derp/derphttp"
 	_ "tailscale.com/derp/derphttp"
 	_ "tailscale.com/drive/driveimpl"
 	_ "tailscale.com/drive/driveimpl"
 	_ "tailscale.com/envknob"
 	_ "tailscale.com/envknob"
+	_ "tailscale.com/feature"
 	_ "tailscale.com/feature/condregister"
 	_ "tailscale.com/feature/condregister"
 	_ "tailscale.com/health"
 	_ "tailscale.com/health"
 	_ "tailscale.com/hostinfo"
 	_ "tailscale.com/hostinfo"

+ 1 - 0
tstest/integration/tailscaled_deps_test_windows.go

@@ -25,6 +25,7 @@ import (
 	_ "tailscale.com/derp/derphttp"
 	_ "tailscale.com/derp/derphttp"
 	_ "tailscale.com/drive/driveimpl"
 	_ "tailscale.com/drive/driveimpl"
 	_ "tailscale.com/envknob"
 	_ "tailscale.com/envknob"
+	_ "tailscale.com/feature"
 	_ "tailscale.com/feature/condregister"
 	_ "tailscale.com/feature/condregister"
 	_ "tailscale.com/health"
 	_ "tailscale.com/health"
 	_ "tailscale.com/hostinfo"
 	_ "tailscale.com/hostinfo"