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>main
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