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