mirror of https://github.com/tailscale/tailscale/
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 <bradfitz@tailscale.com>pull/17117/head
parent
82c5024f03
commit
a1dcf12b67
@ -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()
|
||||
}
|
||||
@ -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"
|
||||
@ -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
|
||||
@ -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)
|
||||
}
|
||||
@ -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,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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue