tailscale: update tailfs file and package names (#11590)

This change updates the tailfs file and package names to their new
naming convention.

Updates #tailscale/corp#16827

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
This commit is contained in:
Charlotte Brandhorst-Satzkorn
2024-04-02 13:32:30 -07:00
committed by GitHub
parent 1c259100b0
commit 14683371ee
58 changed files with 180 additions and 180 deletions

44
drive/drive_clone.go Normal file
View File

@@ -0,0 +1,44 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
package drive
// Clone makes a deep copy of Share.
// The result aliases no memory with the original.
func (src *Share) Clone() *Share {
if src == nil {
return nil
}
dst := new(Share)
*dst = *src
dst.BookmarkData = append(src.BookmarkData[:0:0], src.BookmarkData...)
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _ShareCloneNeedsRegeneration = Share(struct {
Name string
Path string
As string
BookmarkData []byte
}{})
// Clone duplicates src into dst and reports whether it succeeded.
// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>,
// where T is one of Share.
func Clone(dst, src any) bool {
switch src := src.(type) {
case *Share:
switch dst := dst.(type) {
case *Share:
*dst = *src.Clone()
return true
case **Share:
*dst = src.Clone()
return true
}
}
return false
}

75
drive/drive_view.go Normal file
View File

@@ -0,0 +1,75 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale/cmd/viewer; DO NOT EDIT.
package drive
import (
"encoding/json"
"errors"
"tailscale.com/types/views"
)
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type=Share
// View returns a readonly view of Share.
func (p *Share) View() ShareView {
return ShareView{ж: p}
}
// ShareView provides a read-only view over Share.
//
// Its methods should only be called if `Valid()` returns true.
type ShareView struct {
// ж is the underlying mutable value, named with a hard-to-type
// character that looks pointy like a pointer.
// It is named distinctively to make you think of how dangerous it is to escape
// to callers. You must not let callers be able to mutate it.
ж *Share
}
// Valid reports whether underlying value is non-nil.
func (v ShareView) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v ShareView) AsStruct() *Share {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v ShareView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *ShareView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x Share
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v ShareView) Name() string { return v.ж.Name }
func (v ShareView) Path() string { return v.ж.Path }
func (v ShareView) As() string { return v.ж.As }
func (v ShareView) BookmarkData() views.ByteSlice[[]byte] {
return views.ByteSliceOf(v.ж.BookmarkData)
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _ShareViewNeedsRegeneration = Share(struct {
Name string
Path string
As string
BookmarkData []byte
}{})

View File

@@ -0,0 +1,83 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package driveimpl
import (
"context"
"io/fs"
"os"
"time"
"github.com/djherbis/times"
"github.com/tailscale/xnet/webdav"
)
// birthTimingFS extends a webdav.FileSystem to return FileInfos that implement
// the webdav.BirthTimer interface.
type birthTimingFS struct {
webdav.FileSystem
}
func (fs *birthTimingFS) Stat(ctx context.Context, name string) (os.FileInfo, error) {
fi, err := fs.FileSystem.Stat(ctx, name)
if err != nil {
return nil, err
}
return &birthTimingFileInfo{fi}, nil
}
func (fs *birthTimingFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
f, err := fs.FileSystem.OpenFile(ctx, name, flag, perm)
if err != nil {
return nil, err
}
return &birthTimingFile{f}, nil
}
// birthTimingFileInfo extends an os.FileInfo to implement the BirthTimer
// interface.
type birthTimingFileInfo struct {
os.FileInfo
}
func (fi *birthTimingFileInfo) BirthTime(ctx context.Context) (time.Time, error) {
if fi.Sys() == nil {
return time.Time{}, webdav.ErrNotImplemented
}
if !times.HasBirthTime {
return time.Time{}, webdav.ErrNotImplemented
}
return times.Get(fi.FileInfo).BirthTime(), nil
}
// birthTimingFile extends a webdav.File to return FileInfos that implement the
// BirthTimer interface.
type birthTimingFile struct {
webdav.File
}
func (f *birthTimingFile) Stat() (fs.FileInfo, error) {
fi, err := f.File.Stat()
if err != nil {
return nil, err
}
return &birthTimingFileInfo{fi}, nil
}
func (f *birthTimingFile) Readdir(count int) ([]fs.FileInfo, error) {
fis, err := f.File.Readdir(count)
if err != nil {
return nil, err
}
for i, fi := range fis {
fis[i] = &birthTimingFileInfo{fi}
}
return fis, nil
}

View File

@@ -0,0 +1,104 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// BirthTime is not supported on Linux, so only run the test on windows and Mac.
//go:build windows || darwin
package driveimpl
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/tailscale/xnet/webdav"
)
func TestBirthTiming(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
fs := &birthTimingFS{webdav.Dir(dir)}
// create a file
filename := "thefile"
fullPath := filepath.Join(dir, filename)
err := os.WriteFile(fullPath, []byte("hello beautiful world"), 0644)
if err != nil {
t.Fatalf("writing file failed: %s", err)
}
// wait a little bit
time.Sleep(1 * time.Second)
// append to the file to change its mtime
file, err := os.OpenFile(fullPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
t.Fatalf("opening file failed: %s", err)
}
_, err = file.Write([]byte("lookin' good!"))
if err != nil {
t.Fatalf("appending to file failed: %s", err)
}
err = file.Close()
if err != nil {
t.Fatalf("closing file failed: %s", err)
}
checkFileInfo := func(fi os.FileInfo) {
if fi.ModTime().IsZero() {
t.Fatal("FileInfo should have a non-zero ModTime")
}
bt, ok := fi.(webdav.BirthTimer)
if !ok {
t.Fatal("FileInfo should be a BirthTimer")
}
birthTime, err := bt.BirthTime(ctx)
if err != nil {
t.Fatalf("BirthTime() failed: %s", err)
}
if birthTime.IsZero() {
t.Fatal("BirthTime() should return a non-zero time")
}
if !fi.ModTime().After(birthTime) {
t.Fatal("ModTime() should be after BirthTime()")
}
}
fi, err := fs.Stat(ctx, filename)
if err != nil {
t.Fatalf("statting file failed: %s", err)
}
checkFileInfo(fi)
wfile, err := fs.OpenFile(ctx, filename, os.O_RDONLY, 0)
if err != nil {
t.Fatalf("opening file failed: %s", err)
}
defer wfile.Close()
fi, err = wfile.Stat()
if err != nil {
t.Fatalf("statting file failed: %s", err)
}
if fi == nil {
t.Fatal("statting file returned nil FileInfo")
}
checkFileInfo(fi)
dfile, err := fs.OpenFile(ctx, ".", os.O_RDONLY, 0)
if err != nil {
t.Fatalf("opening directory failed: %s", err)
}
defer dfile.Close()
fis, err := dfile.Readdir(0)
if err != nil {
t.Fatalf("readdir failed: %s", err)
}
if len(fis) != 1 {
t.Fatalf("readdir should have returned 1 file info, but returned %d", 1)
}
checkFileInfo(fis[0])
}

View File

@@ -0,0 +1,233 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package compositedav provides an http.Handler that composes multiple WebDAV
// services into a single WebDAV service that presents each of them as its own
// folder.
package compositedav
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"path"
"slices"
"strings"
"sync"
"github.com/tailscale/xnet/webdav"
"tailscale.com/drive/driveimpl/dirfs"
"tailscale.com/drive/driveimpl/shared"
"tailscale.com/tstime"
"tailscale.com/types/logger"
)
// Child is a child folder of this compositedav.
type Child struct {
*dirfs.Child
// BaseURL is the base URL of the WebDAV service to which we'll proxy
// requests for this Child. We will append the filename from the original
// URL to this.
BaseURL string
// Transport (if specified) is the http transport to use when communicating
// with this Child's WebDAV service.
Transport http.RoundTripper
rp *httputil.ReverseProxy
initOnce sync.Once
}
// CloseIdleConnections forcibly closes any idle connections on this Child's
// reverse proxy.
func (c *Child) CloseIdleConnections() {
tr, ok := c.Transport.(*http.Transport)
if ok {
tr.CloseIdleConnections()
}
}
func (c *Child) init() {
c.initOnce.Do(func() {
c.rp = &httputil.ReverseProxy{
Transport: c.Transport,
Rewrite: func(r *httputil.ProxyRequest) {},
}
})
}
// Handler implements http.Handler by using a dirfs.FS for showing a virtual
// read-only folder that represents the Child WebDAV services as sub-folders
// and proxying all requests for resources on the children to those children
// via httputil.ReverseProxy instances.
type Handler struct {
// Logf specifies a logging function to use.
Logf logger.Logf
// Clock, if specified, determines the current time. If not specified, we
// default to time.Now().
Clock tstime.Clock
// StatCache is an optional cache for PROPFIND results.
StatCache *StatCache
// childrenMu guards the fields below. Note that we do read the contents of
// children after releasing the read lock, which we can do because we never
// modify children but only ever replace it in SetChildren.
childrenMu sync.RWMutex
children []*Child
staticRoot string
}
// ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == "PROPFIND" {
h.handlePROPFIND(w, r)
return
}
if r.Method != "GET" {
// If the user is performing a modification (e.g. PUT, MKDIR, etc),
// we need to invalidate the StatCache to make sure we're not knowingly
// showing stale stats.
// TODO(oxtoacart): maybe be more selective about invalidating cache
h.StatCache.invalidate()
}
mpl := h.maxPathLength(r)
pathComponents := shared.CleanAndSplit(r.URL.Path)
if len(pathComponents) >= mpl {
h.delegate(pathComponents[mpl-1:], w, r)
return
}
h.handle(w, r)
}
// handle handles the request locally using our dirfs.FS.
func (h *Handler) handle(w http.ResponseWriter, r *http.Request) {
h.childrenMu.RLock()
clk, kids, root := h.Clock, h.children, h.staticRoot
h.childrenMu.RUnlock()
children := make([]*dirfs.Child, 0, len(kids))
for _, child := range kids {
children = append(children, child.Child)
}
wh := &webdav.Handler{
LockSystem: webdav.NewMemLS(),
FileSystem: &dirfs.FS{
Clock: clk,
Children: children,
StaticRoot: root,
},
}
wh.ServeHTTP(w, r)
}
// delegate sends the request to the Child WebDAV server.
func (h *Handler) delegate(pathComponents []string, w http.ResponseWriter, r *http.Request) string {
childName := pathComponents[0]
child := h.GetChild(childName)
if child == nil {
w.WriteHeader(http.StatusNotFound)
return childName
}
u, err := url.Parse(child.BaseURL)
if err != nil {
h.logf("warning: parse base URL %s failed: %s", child.BaseURL, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return childName
}
u.Path = path.Join(u.Path, shared.Join(pathComponents[1:]...))
r.URL = u
r.Host = u.Host
child.rp.ServeHTTP(w, r)
return childName
}
// SetChildren replaces the entire existing set of children with the given
// ones. If staticRoot is given, the children will appear with a subfolder
// bearing named <staticRoot>.
func (h *Handler) SetChildren(staticRoot string, children ...*Child) {
for _, child := range children {
child.init()
}
slices.SortFunc(children, func(a, b *Child) int {
return strings.Compare(a.Name, b.Name)
})
h.childrenMu.Lock()
oldChildren := children
h.children = children
h.staticRoot = staticRoot
h.childrenMu.Unlock()
for _, child := range oldChildren {
child.CloseIdleConnections()
}
}
// GetChild gets the Child identified by name, or nil if no matching child
// found.
func (h *Handler) GetChild(name string) *Child {
h.childrenMu.RLock()
defer h.childrenMu.RUnlock()
_, child := h.findChildLocked(name)
return child
}
// Close closes this Handler,including closing all idle connections on children
// and stopping the StatCache (if caching is enabled).
func (h *Handler) Close() {
h.childrenMu.RLock()
oldChildren := h.children
h.children = nil
h.childrenMu.RUnlock()
for _, child := range oldChildren {
child.CloseIdleConnections()
}
if h.StatCache != nil {
h.StatCache.stop()
}
}
func (h *Handler) findChildLocked(name string) (int, *Child) {
var child *Child
i, found := slices.BinarySearchFunc(h.children, name, func(child *Child, name string) int {
return strings.Compare(child.Name, name)
})
if found {
return i, h.children[i]
}
return i, child
}
func (h *Handler) logf(format string, args ...any) {
if h.Logf != nil {
h.Logf(format, args...)
return
}
log.Printf(format, args...)
}
// maxPathLength calculates the maximum length of a path that can be handled by
// this handler without delegating to a Child. It's always at least 1, and if
// staticRoot is configured, it's 2.
func (h *Handler) maxPathLength(r *http.Request) int {
h.childrenMu.RLock()
defer h.childrenMu.RUnlock()
if h.staticRoot != "" {
return 2
}
return 1
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositedav
import (
"bytes"
"fmt"
"math"
"net/http"
"regexp"
"tailscale.com/drive/driveimpl/shared"
)
var (
hrefRegex = regexp.MustCompile(`(?s)<D:href>/?([^<]*)/?</D:href>`)
)
func (h *Handler) handlePROPFIND(w http.ResponseWriter, r *http.Request) {
pathComponents := shared.CleanAndSplit(r.URL.Path)
mpl := h.maxPathLength(r)
if !shared.IsRoot(r.URL.Path) && len(pathComponents)+getDepth(r) > mpl {
// Delegate to a Child.
depth := getDepth(r)
cached := h.StatCache.get(r.URL.Path, depth)
if cached != nil {
w.Header().Del("Content-Length")
w.WriteHeader(http.StatusMultiStatus)
w.Write(cached)
return
}
// Use a buffering ResponseWriter so that we can manipulate the result.
// The only thing we use from the original ResponseWriter is Header().
bw := &bufferingResponseWriter{ResponseWriter: w}
mpl := h.maxPathLength(r)
h.delegate(pathComponents[mpl-1:], bw, r)
// Fixup paths to add the requested path as a prefix.
pathPrefix := shared.Join(pathComponents[0:mpl]...)
b := hrefRegex.ReplaceAll(bw.buf.Bytes(), []byte(fmt.Sprintf("<D:href>%s/$1</D:href>", pathPrefix)))
if h.StatCache != nil && bw.status == http.StatusMultiStatus && b != nil {
h.StatCache.set(r.URL.Path, depth, b)
}
w.Header().Del("Content-Length")
w.WriteHeader(bw.status)
w.Write(b)
return
}
h.handle(w, r)
}
func getDepth(r *http.Request) int {
switch r.Header.Get("Depth") {
case "0":
return 0
case "1":
return 1
case "infinity":
return math.MaxInt
}
return 0
}
type bufferingResponseWriter struct {
http.ResponseWriter
status int
buf bytes.Buffer
}
func (bw *bufferingResponseWriter) WriteHeader(statusCode int) {
bw.status = statusCode
}
func (bw *bufferingResponseWriter) Write(p []byte) (int, error) {
return bw.buf.Write(p)
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositedav
import (
"sync"
"time"
"github.com/jellydator/ttlcache/v3"
)
// StatCache provides a cache for directory listings and file metadata.
// Especially when used from the command-line, mapped WebDAV drives can
// generate repetitive requests for the same file metadata. This cache helps
// reduce the number of round-trips to the WebDAV server for such requests.
// This is similar to the DirectoryCacheLifetime setting of Windows' built-in
// SMB client, see
// https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-7/ff686200(v=ws.10)
type StatCache struct {
TTL time.Duration
// mu guards the below values.
mu sync.Mutex
cachesByDepthAndPath map[int]*ttlcache.Cache[string, []byte]
}
func (c *StatCache) get(name string, depth int) []byte {
if c == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if c.cachesByDepthAndPath == nil {
return nil
}
cache := c.cachesByDepthAndPath[depth]
if cache == nil {
return nil
}
item := cache.Get(name)
if item == nil {
return nil
}
return item.Value()
}
func (c *StatCache) set(name string, depth int, value []byte) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.cachesByDepthAndPath == nil {
c.cachesByDepthAndPath = make(map[int]*ttlcache.Cache[string, []byte])
}
cache := c.cachesByDepthAndPath[depth]
if cache == nil {
cache = ttlcache.New(
ttlcache.WithTTL[string, []byte](c.TTL),
)
go cache.Start()
c.cachesByDepthAndPath[depth] = cache
}
cache.Set(name, value, ttlcache.DefaultTTL)
}
func (c *StatCache) invalidate() {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
for _, cache := range c.cachesByDepthAndPath {
cache.DeleteAll()
}
}
func (c *StatCache) stop() {
c.mu.Lock()
defer c.mu.Unlock()
for _, cache := range c.cachesByDepthAndPath {
cache.Stop()
}
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositedav
import (
"bytes"
"testing"
"time"
"tailscale.com/tstest"
)
var (
val = []byte("1")
file = "file"
)
func TestStatCacheNoTimeout(t *testing.T) {
// Make sure we don't leak goroutines
tstest.ResourceCheck(t)
c := &StatCache{TTL: 5 * time.Second}
defer c.stop()
// check get before set
fetched := c.get(file, 1)
if fetched != nil {
t.Errorf("got %q, want nil", fetched)
}
// set new stat
c.set(file, 1, val)
fetched = c.get(file, 1)
if !bytes.Equal(fetched, val) {
t.Errorf("got %q, want %q", fetched, val)
}
// fetch stat again, should still be cached
fetched = c.get(file, 1)
if !bytes.Equal(fetched, val) {
t.Errorf("got %q, want %q", fetched, val)
}
}
func TestStatCacheTimeout(t *testing.T) {
// Make sure we don't leak goroutines
tstest.ResourceCheck(t)
c := &StatCache{TTL: 250 * time.Millisecond}
defer c.stop()
// set new stat
c.set(file, 1, val)
fetched := c.get(file, 1)
if !bytes.Equal(fetched, val) {
t.Errorf("got %q, want %q", fetched, val)
}
// wait for cache to expire and refetch stat, should be empty now
time.Sleep(c.TTL * 2)
fetched = c.get(file, 1)
if fetched != nil {
t.Errorf("invalidate should have cleared cached value")
}
c.set(file, 1, val)
// invalidate the cache and make sure nothing is returned
c.invalidate()
fetched = c.get(file, 1)
if fetched != nil {
t.Errorf("invalidate should have cleared cached value")
}
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package driveimpl
import (
"log"
"net"
"sync"
"syscall"
)
type connListener struct {
ch chan net.Conn
closedCh chan any
closeMu sync.Mutex
}
// newConnListener creates a net.Listener to which one can hand connections
// directly.
func newConnListener() *connListener {
return &connListener{
ch: make(chan net.Conn),
closedCh: make(chan any),
}
}
func (l *connListener) Accept() (net.Conn, error) {
select {
case <-l.closedCh:
// TODO(oxtoacart): make this error match what a regular net.Listener does
return nil, syscall.EINVAL
case conn := <-l.ch:
return conn, nil
}
}
// Addr implements net.Listener. This always returns nil. It is assumed that
// this method is currently unused, so it logs a warning if it ever does get
// called.
func (l *connListener) Addr() net.Addr {
log.Println("warning: unexpected call to connListener.Addr()")
return nil
}
func (l *connListener) Close() error {
l.closeMu.Lock()
defer l.closeMu.Unlock()
select {
case <-l.closedCh:
// Already closed.
return syscall.EINVAL
default:
// We don't close l.ch because someone maybe trying to send to that,
// which would cause a panic.
close(l.closedCh)
return nil
}
}
func (l *connListener) HandleConn(c net.Conn, remoteAddr net.Addr) error {
select {
case <-l.closedCh:
return syscall.EINVAL
case l.ch <- &connWithRemoteAddr{Conn: c, remoteAddr: remoteAddr}:
// Connection has been accepted.
}
return nil
}
type connWithRemoteAddr struct {
net.Conn
remoteAddr net.Addr
}
func (c *connWithRemoteAddr) RemoteAddr() net.Addr {
return c.remoteAddr
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package driveimpl
import (
"log"
"net"
"testing"
)
func TestConnListener(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:")
if err != nil {
t.Fatalf("failed to Listen: %s", err)
}
cl := newConnListener()
// Test that we can accept a connection
cc, err := net.Dial("tcp", l.Addr().String())
if err != nil {
t.Fatalf("failed to Dial: %s", err)
}
defer cc.Close()
sc, err := l.Accept()
if err != nil {
t.Fatalf("failed to Accept: %s", err)
}
remoteAddr := &net.TCPAddr{IP: net.ParseIP("10.10.10.10"), Port: 1234}
go func() {
err := cl.HandleConn(sc, remoteAddr)
if err != nil {
log.Printf("failed to HandleConn: %s", err)
}
}()
clc, err := cl.Accept()
if err != nil {
t.Fatalf("failed to Accept: %s", err)
}
defer clc.Close()
if clc.RemoteAddr().String() != remoteAddr.String() {
t.Fatalf("ConnListener accepted the wrong connection, got %q, want %q", clc.RemoteAddr(), remoteAddr)
}
err = cl.Close()
if err != nil {
t.Fatalf("failed to Close: %s", err)
}
err = cl.Close()
if err == nil {
t.Fatal("should have failed on second Close")
}
err = cl.HandleConn(sc, remoteAddr)
if err == nil {
t.Fatal("should have failed on HandleConn after Close")
}
_, err = cl.Accept()
if err == nil {
t.Fatal("should have failed on Accept after Close")
}
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package dirfs provides a webdav.FileSystem that looks like a read-only
// directory containing only subdirectories.
package dirfs
import (
"slices"
"strings"
"time"
"tailscale.com/drive/driveimpl/shared"
"tailscale.com/tstime"
)
// Child is subdirectory of an FS.
type Child struct {
// Name is the name of the child
Name string
// Available is a function indicating whether or not the child is currently
// available. Unavailable children are excluded from the FS's directory
// listing. Available must be safe for concurrent use.
Available func() bool
}
func (c *Child) isAvailable() bool {
if c.Available == nil {
return true
}
return c.Available()
}
// FS is a read-only webdav.FileSystem that is composed of multiple child
// folders.
//
// When listing the contents of this FileSystem's root directory, children will
// be ordered in the order they're given to the FS.
//
// Children in an FS cannot be added, removed or renamed via operations on the
// webdav.FileSystem interface like filesystem.Mkdir or filesystem.OpenFile.
//
// Any attempts to perform operations on paths inside of children will result
// in a panic, as these are not expected to be performed on this FS.
//
// An FS an optionally have a StaticRoot, which will insert a folder with that
// StaticRoot into the tree, like this:
//
// -- <StaticRoot>
// ----- <Child>
// ----- <Child>
type FS struct {
// Children configures the full set of children of this FS.
Children []*Child
// Clock, if given, will cause this FS to use Clock.now() as the current
// time.
Clock tstime.Clock
// StaticRoot, if given, will insert the given name as a static root into
// every path.
StaticRoot string
}
func (dfs *FS) findChild(name string) (int, *Child) {
var child *Child
i, found := slices.BinarySearchFunc(dfs.Children, name, func(child *Child, name string) int {
return strings.Compare(child.Name, name)
})
if found {
child = dfs.Children[i]
}
return i, child
}
// childFor returns the child for the given filename. If the filename refers to
// a path inside of a child, this will panic.
func (dfs *FS) childFor(name string) *Child {
pathComponents := shared.CleanAndSplit(name)
if len(pathComponents) != 1 {
panic("dirfs does not permit reaching into child directories")
}
_, child := dfs.findChild(pathComponents[0])
return child
}
func (dfs *FS) now() time.Time {
if dfs.Clock != nil {
return dfs.Clock.Now()
}
return time.Now()
}
func (dfs *FS) trimStaticRoot(name string) (string, bool) {
before, after, found := strings.Cut(name, "/"+dfs.StaticRoot)
if !found {
return before, false
}
return after, shared.IsRoot(after)
}

View File

@@ -0,0 +1,348 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"errors"
"io/fs"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/tailscale/xnet/webdav"
"tailscale.com/drive/driveimpl/shared"
"tailscale.com/tstest"
)
func TestStat(t *testing.T) {
cfs, _, _, clock := createFileSystem(t)
tests := []struct {
label string
name string
expected fs.FileInfo
err error
}{
{
label: "root folder",
name: "",
expected: &shared.StaticFileInfo{
Named: "",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "static root folder",
name: "/domain",
expected: &shared.StaticFileInfo{
Named: "domain",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "remote1",
name: "/domain/remote1",
expected: &shared.StaticFileInfo{
Named: "remote1",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "remote2",
name: "/domain/remote2",
expected: &shared.StaticFileInfo{
Named: "remote2",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "non-existent remote",
name: "remote3",
err: os.ErrNotExist,
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
fi, err := cfs.Stat(ctx, test.name)
if test.err != nil {
if !errors.Is(err, test.err) {
t.Errorf("got %v, want %v", err, test.err)
}
} else {
if err != nil {
t.Errorf("unable to stat file: %v", err)
} else {
infosEqual(t, test.expected, fi)
}
}
})
}
}
func TestListDir(t *testing.T) {
cfs, _, _, clock := createFileSystem(t)
tests := []struct {
label string
name string
expected []fs.FileInfo
err error
}{
{
label: "root folder",
name: "",
expected: []fs.FileInfo{
&shared.StaticFileInfo{
Named: "domain",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
},
{
label: "static root folder",
name: "/domain",
expected: []fs.FileInfo{
&shared.StaticFileInfo{
Named: "remote1",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
&shared.StaticFileInfo{
Named: "remote2",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
&shared.StaticFileInfo{
Named: "remote4",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
var infos []fs.FileInfo
file, err := cfs.OpenFile(ctx, test.name, os.O_RDONLY, 0)
if err == nil {
defer file.Close()
infos, err = file.Readdir(0)
}
if test.err != nil {
if !errors.Is(err, test.err) {
t.Errorf("got %v, want %v", err, test.err)
}
} else {
if err != nil {
t.Errorf("unable to stat file: %v", err)
} else {
if len(infos) != len(test.expected) {
t.Errorf("wrong number of file infos, want %d, got %d", len(test.expected), len(infos))
} else {
for i, expected := range test.expected {
infosEqual(t, expected, infos[i])
}
}
}
}
})
}
}
func TestMkdir(t *testing.T) {
fs, _, _, _ := createFileSystem(t)
tests := []struct {
label string
name string
perm os.FileMode
err error
}{
{
label: "attempt to create root folder",
name: "/",
},
{
label: "attempt to create static root folder",
name: "/domain",
},
{
label: "attempt to create remote",
name: "/domain/remote1",
},
{
label: "attempt to create non-existent remote",
name: "/domain/remote3",
err: os.ErrPermission,
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
err := fs.Mkdir(ctx, test.name, test.perm)
if test.err != nil {
if !errors.Is(err, test.err) {
t.Errorf("got %v, want %v", err, test.err)
}
} else if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestRemoveAll(t *testing.T) {
fs, _, _, _ := createFileSystem(t)
tests := []struct {
label string
name string
err error
}{
{
label: "attempt to remove root folder",
name: "/",
err: os.ErrPermission,
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
err := fs.RemoveAll(ctx, test.name)
if !errors.Is(err, test.err) {
t.Errorf("got %v, want %v", err, test.err)
}
})
}
}
func TestRename(t *testing.T) {
fs, _, _, _ := createFileSystem(t)
tests := []struct {
label string
oldName string
newName string
err error
}{
{
label: "attempt to move root folder",
oldName: "/",
newName: "/domain/remote2/copy.txt",
err: os.ErrPermission,
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
err := fs.Rename(ctx, test.oldName, test.newName)
if !errors.Is(err, test.err) {
t.Errorf("got %v, want: %v", err, test.err)
}
})
}
}
func createFileSystem(t *testing.T) (webdav.FileSystem, string, string, *tstest.Clock) {
s1, dir1 := startRemote(t)
s2, dir2 := startRemote(t)
// Make some files, use perms 0666 as lowest common denominator that works
// on both UNIX and Windows.
err := os.WriteFile(filepath.Join(dir1, "file1.txt"), []byte("12345"), 0666)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(dir2, "file2.txt"), []byte("54321"), 0666)
if err != nil {
t.Fatal(err)
}
// make some directories
err = os.Mkdir(filepath.Join(dir1, "dir1"), 0666)
if err != nil {
t.Fatal(err)
}
err = os.Mkdir(filepath.Join(dir2, "dir2"), 0666)
if err != nil {
t.Fatal(err)
}
clock := tstest.NewClock(tstest.ClockOpts{Start: time.Now()})
fs := &FS{
Clock: clock,
StaticRoot: "domain",
Children: []*Child{
{Name: "remote1"},
{Name: "remote2"},
{Name: "remote4"},
},
}
t.Cleanup(func() {
defer s1.Close()
defer os.RemoveAll(dir1)
defer s2.Close()
defer os.RemoveAll(dir2)
})
return fs, dir1, dir2, clock
}
func startRemote(t *testing.T) (*httptest.Server, string) {
dir := t.TempDir()
h := &webdav.Handler{
FileSystem: webdav.Dir(dir),
LockSystem: webdav.NewMemLS(),
}
s := httptest.NewServer(h)
t.Cleanup(s.Close)
return s, dir
}
func infosEqual(t *testing.T, expected, actual fs.FileInfo) {
t.Helper()
sfi, ok := actual.(*shared.StaticFileInfo)
if ok {
// zero out BirthedTime because we don't want to compare that
sfi.BirthedTime = time.Time{}
}
if diff := cmp.Diff(actual, expected); diff != "" {
t.Errorf("Wrong file info (-got, +want):\n%s", diff)
}
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"os"
"tailscale.com/drive/driveimpl/shared"
)
// Mkdir implements webdav.FileSystem. All attempts to Mkdir a directory that
// already exists will succeed. All other attempts will fail with
// os.ErrPermission.
func (dfs *FS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
nameWithoutStaticRoot, isStaticRoot := dfs.trimStaticRoot(name)
if isStaticRoot || shared.IsRoot(name) {
// root directory already exists, consider this okay
return nil
}
child := dfs.childFor(nameWithoutStaticRoot)
if child != nil {
// child already exists, consider this okay
return nil
}
return &os.PathError{Op: "mkdir", Path: name, Err: os.ErrPermission}
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"io/fs"
"os"
"github.com/tailscale/xnet/webdav"
"tailscale.com/drive/driveimpl/shared"
)
// OpenFile implements interface webdav.Filesystem.
func (dfs *FS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
_, isStaticRoot := dfs.trimStaticRoot(name)
if !isStaticRoot && !shared.IsRoot(name) {
// Show a folder with no children to represent the requested child. In
// practice, the children of this folder are never read, we just need
// to give webdav a file here which it uses to call file.Stat(). So,
// even though the Child may in fact have its own children, it doesn't
// matter here.
return &shared.DirFile{
Info: shared.ReadOnlyDirInfo(name, dfs.now()),
LoadChildren: func() ([]fs.FileInfo, error) {
return nil, nil
},
}, nil
}
di, err := dfs.Stat(ctx, name)
if err != nil {
return nil, err
}
if dfs.StaticRoot != "" && !isStaticRoot {
// Show a folder with a single subfolder that is the static root.
return &shared.DirFile{
Info: di,
LoadChildren: func() ([]fs.FileInfo, error) {
return []fs.FileInfo{
shared.ReadOnlyDirInfo(dfs.StaticRoot, dfs.now()),
}, nil
},
}, nil
}
// Show a folder with one subfolder for each Child of this FS.
return &shared.DirFile{
Info: di,
LoadChildren: func() ([]fs.FileInfo, error) {
childInfos := make([]fs.FileInfo, 0, len(dfs.Children))
for _, c := range dfs.Children {
if c.isAvailable() {
childInfo := shared.ReadOnlyDirInfo(c.Name, dfs.now())
childInfos = append(childInfos, childInfo)
}
}
return childInfos, nil
},
}, nil
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"os"
)
// RemoveAll implements webdav.File. No removal is supported and this always
// returns os.ErrPermission.
func (dfs *FS) RemoveAll(ctx context.Context, name string) error {
return &os.PathError{Op: "rm", Path: name, Err: os.ErrPermission}
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"os"
)
// Rename implements interface webdav.FileSystem. No renaming is supported and
// this always returns os.ErrPermission.
func (dfs *FS) Rename(ctx context.Context, oldName, newName string) error {
return &os.PathError{Op: "mv", Path: oldName, Err: os.ErrPermission}
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"io/fs"
"os"
"tailscale.com/drive/driveimpl/shared"
)
// Stat implements webdav.FileSystem.
func (dfs *FS) Stat(ctx context.Context, name string) (fs.FileInfo, error) {
nameWithoutStaticRoot, isStaticRoot := dfs.trimStaticRoot(name)
if isStaticRoot || shared.IsRoot(name) {
// Static root is a directory, always use now() as the modified time to
// bust caches.
fi := shared.ReadOnlyDirInfo(name, dfs.now())
return fi, nil
}
child := dfs.childFor(nameWithoutStaticRoot)
if child == nil {
return nil, &os.PathError{Op: "stat", Path: name, Err: os.ErrNotExist}
}
return shared.ReadOnlyDirInfo(name, dfs.now()), nil
}

View File

@@ -0,0 +1,410 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package driveimpl
import (
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"os"
"path"
"path/filepath"
"slices"
"sync"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/studio-b12/gowebdav"
"tailscale.com/drive"
"tailscale.com/drive/driveimpl/shared"
"tailscale.com/tstest"
)
const (
domain = `test$%domain.com`
remote1 = `rem ote$%1`
remote2 = `_rem ote$%2`
share11 = `sha re$%11`
share12 = `_sha re$%12`
file111 = `fi le$%111.txt`
)
func init() {
// set AllowShareAs() to false so that we don't try to use sub-processes
// for access files on disk.
drive.DisallowShareAs = true
}
// The tests in this file simulate real-life TailFS scenarios, but without
// going over the Tailscale network stack.
func TestDirectoryListing(t *testing.T) {
s := newSystem(t)
s.addRemote(remote1)
s.checkDirList("root directory should contain the one and only domain once a remote has been set", "/", domain)
s.checkDirList("domain should contain its only remote", shared.Join(domain), remote1)
s.checkDirList("remote with no shares should be empty", shared.Join(domain, remote1))
s.addShare(remote1, share11, drive.PermissionReadWrite)
s.checkDirList("remote with one share should contain that share", shared.Join(domain, remote1), share11)
s.addShare(remote1, share12, drive.PermissionReadOnly)
s.checkDirList("remote with two shares should contain both in lexicographical order", shared.Join(domain, remote1), share12, share11)
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true)
s.checkDirList("remote share should contain file", shared.Join(domain, remote1, share11), file111)
s.addRemote(remote2)
s.checkDirList("domain with two remotes should contain both in lexicographical order", shared.Join(domain), remote2, remote1)
s.freezeRemote(remote1)
s.checkDirList("domain with two remotes should contain both in lexicographical order even if one is unreachable", shared.Join(domain), remote2, remote1)
_, err := s.client.ReadDir(shared.Join(domain, remote1))
if err == nil {
t.Error("directory listing for offline remote should fail")
}
s.unfreezeRemote(remote1)
s.checkDirList("attempt at lateral traversal should simply list shares", shared.Join(domain, remote1, share11, ".."), share12, share11)
}
func TestFileManipulation(t *testing.T) {
s := newSystem(t)
s.addRemote(remote1)
s.addShare(remote1, share11, drive.PermissionReadWrite)
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true)
s.checkFileStatus(remote1, share11, file111)
s.checkFileContents(remote1, share11, file111)
s.addShare(remote1, share12, drive.PermissionReadOnly)
s.writeFile("writing file to read-only remote should fail", remote1, share12, file111, "hello world", false)
s.writeFile("writing file to non-existent remote should fail", "non-existent", share11, file111, "hello world", false)
s.writeFile("writing file to non-existent share should fail", remote1, "non-existent", file111, "hello world", false)
}
type local struct {
l net.Listener
fs *FileSystemForLocal
}
type remote struct {
l net.Listener
fs *FileSystemForRemote
fileServer *FileServer
shares map[string]string
permissions map[string]drive.Permission
mu sync.RWMutex
}
func (r *remote) freeze() {
r.mu.Lock()
}
func (r *remote) unfreeze() {
r.mu.Unlock()
}
func (r *remote) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mu.RLock()
defer r.mu.RUnlock()
r.fs.ServeHTTPWithPerms(r.permissions, w, req)
}
type system struct {
t *testing.T
local *local
client *gowebdav.Client
remotes map[string]*remote
}
func newSystem(t *testing.T) *system {
// Make sure we don't leak goroutines
tstest.ResourceCheck(t)
fs := NewFileSystemForLocal(log.Printf)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to Listen: %s", err)
}
t.Logf("FileSystemForLocal listening at %s", l.Addr())
go func() {
for {
conn, err := l.Accept()
if err != nil {
t.Logf("Accept: %v", err)
return
}
go fs.HandleConn(conn, conn.RemoteAddr())
}
}()
client := gowebdav.NewAuthClient(fmt.Sprintf("http://%s", l.Addr()), &noopAuthorizer{})
client.SetTransport(&http.Transport{DisableKeepAlives: true})
s := &system{
t: t,
local: &local{l: l, fs: fs},
client: client,
remotes: make(map[string]*remote),
}
t.Cleanup(s.stop)
return s
}
func (s *system) addRemote(name string) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
s.t.Fatalf("failed to Listen: %s", err)
}
s.t.Logf("Remote for %v listening at %s", name, l.Addr())
fileServer, err := NewFileServer()
if err != nil {
s.t.Fatalf("failed to call NewFileServer: %s", err)
}
go fileServer.Serve()
s.t.Logf("FileServer for %v listening at %s", name, fileServer.Addr())
r := &remote{
l: l,
fileServer: fileServer,
fs: NewFileSystemForRemote(log.Printf),
shares: make(map[string]string),
permissions: make(map[string]drive.Permission),
}
r.fs.SetFileServerAddr(fileServer.Addr())
go http.Serve(l, r)
s.remotes[name] = r
remotes := make([]*drive.Remote, 0, len(s.remotes))
for name, r := range s.remotes {
remotes = append(remotes, &drive.Remote{
Name: name,
URL: fmt.Sprintf("http://%s", r.l.Addr()),
})
}
s.local.fs.SetRemotes(
domain,
remotes,
&http.Transport{
DisableKeepAlives: true,
ResponseHeaderTimeout: 5 * time.Second,
})
}
func (s *system) addShare(remoteName, shareName string, permission drive.Permission) {
r, ok := s.remotes[remoteName]
if !ok {
s.t.Fatalf("unknown remote %q", remoteName)
}
f := s.t.TempDir()
r.shares[shareName] = f
r.permissions[shareName] = permission
shares := make([]*drive.Share, 0, len(r.shares))
for shareName, folder := range r.shares {
shares = append(shares, &drive.Share{
Name: shareName,
Path: folder,
})
}
slices.SortFunc(shares, drive.CompareShares)
r.fs.SetShares(shares)
r.fileServer.SetShares(r.shares)
}
func (s *system) freezeRemote(remoteName string) {
r, ok := s.remotes[remoteName]
if !ok {
s.t.Fatalf("unknown remote %q", remoteName)
}
r.freeze()
}
func (s *system) unfreezeRemote(remoteName string) {
r, ok := s.remotes[remoteName]
if !ok {
s.t.Fatalf("unknown remote %q", remoteName)
}
r.unfreeze()
}
func (s *system) writeFile(label, remoteName, shareName, name, contents string, expectSuccess bool) {
path := pathTo(remoteName, shareName, name)
err := s.client.Write(path, []byte(contents), 0644)
if expectSuccess && err != nil {
s.t.Fatalf("%v: expected success writing file %q, but got error %v", label, path, err)
} else if !expectSuccess && err == nil {
s.t.Fatalf("%v: expected error writing file %q", label, path)
}
}
func (s *system) checkFileStatus(remoteName, shareName, name string) {
expectedFI := s.stat(remoteName, shareName, name)
actualFI := s.statViaWebDAV(remoteName, shareName, name)
s.checkFileInfosEqual(expectedFI, actualFI, fmt.Sprintf("%s/%s/%s should show same FileInfo via WebDAV stat as local stat", remoteName, shareName, name))
}
func (s *system) checkFileContents(remoteName, shareName, name string) {
expected := s.read(remoteName, shareName, name)
actual := s.readViaWebDAV(remoteName, shareName, name)
if expected != actual {
s.t.Errorf("%s/%s/%s should show same contents via WebDAV read as local read\nwant: %q\nhave: %q", remoteName, shareName, name, expected, actual)
}
}
func (s *system) checkDirList(label string, path string, want ...string) {
got, err := s.client.ReadDir(path)
if err != nil {
s.t.Fatalf("failed to Readdir: %s", err)
}
if len(want) == 0 && len(got) == 0 {
return
}
gotNames := make([]string, 0, len(got))
for _, fi := range got {
gotNames = append(gotNames, fi.Name())
}
if diff := cmp.Diff(want, gotNames); diff != "" {
s.t.Errorf("%v: (-got, +want):\n%s", label, diff)
}
}
func (s *system) stat(remoteName, shareName, name string) os.FileInfo {
filename := filepath.Join(s.remotes[remoteName].shares[shareName], name)
fi, err := os.Stat(filename)
if err != nil {
s.t.Fatalf("failed to Stat: %s", err)
}
return fi
}
func (s *system) statViaWebDAV(remoteName, shareName, name string) os.FileInfo {
path := pathTo(remoteName, shareName, name)
fi, err := s.client.Stat(path)
if err != nil {
s.t.Fatalf("failed to Stat: %s", err)
}
return fi
}
func (s *system) read(remoteName, shareName, name string) string {
filename := filepath.Join(s.remotes[remoteName].shares[shareName], name)
b, err := os.ReadFile(filename)
if err != nil {
s.t.Fatalf("failed to ReadFile: %s", err)
}
return string(b)
}
func (s *system) readViaWebDAV(remoteName, shareName, name string) string {
path := pathTo(remoteName, shareName, name)
b, err := s.client.Read(path)
if err != nil {
s.t.Fatalf("failed to OpenFile: %s", err)
}
return string(b)
}
func (s *system) stop() {
err := s.local.fs.Close()
if err != nil {
s.t.Fatalf("failed to Close fs: %s", err)
}
err = s.local.l.Close()
if err != nil {
s.t.Fatalf("failed to Close listener: %s", err)
}
for _, r := range s.remotes {
err = r.fs.Close()
if err != nil {
s.t.Fatalf("failed to Close remote fs: %s", err)
}
err = r.l.Close()
if err != nil {
s.t.Fatalf("failed to Close remote listener: %s", err)
}
err = r.fileServer.Close()
if err != nil {
s.t.Fatalf("failed to Close remote fileserver: %s", err)
}
}
}
func (s *system) checkFileInfosEqual(expected, actual fs.FileInfo, label string) {
if expected == nil && actual == nil {
return
}
diff := cmp.Diff(fileInfoToStatic(expected, true), fileInfoToStatic(actual, false))
if diff != "" {
s.t.Errorf("%v (-got, +want):\n%s", label, diff)
}
}
func fileInfoToStatic(fi fs.FileInfo, fixupMode bool) fs.FileInfo {
mode := fi.Mode()
if fixupMode {
// WebDAV doesn't transmit file modes, so we just mimic the defaults that
// our WebDAV client uses.
mode = os.FileMode(0664)
if fi.IsDir() {
mode = 0775 | os.ModeDir
}
}
return &shared.StaticFileInfo{
Named: fi.Name(),
Sized: fi.Size(),
Moded: mode,
ModdedTime: fi.ModTime().Truncate(1 * time.Second).UTC(),
Dir: fi.IsDir(),
}
}
func pathTo(remote, share, name string) string {
return path.Join(domain, remote, share, name)
}
// noopAuthorizer implements gowebdav.Authorizer. It does no actual
// authorizing. We use it in place of gowebdav's built-in authorizer in order
// to avoid a race condition in that authorizer.
type noopAuthorizer struct{}
func (a *noopAuthorizer) NewAuthenticator(body io.Reader) (gowebdav.Authenticator, io.Reader) {
return &noopAuthenticator{}, nil
}
func (a *noopAuthorizer) AddAuthenticator(key string, fn gowebdav.AuthFactory) {
}
type noopAuthenticator struct{}
func (a *noopAuthenticator) Authorize(c *http.Client, rq *http.Request, path string) error {
return nil
}
func (a *noopAuthenticator) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) {
return false, nil
}
func (a *noopAuthenticator) Clone() gowebdav.Authenticator {
return &noopAuthenticator{}
}
func (a *noopAuthenticator) Close() error {
return nil
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package driveimpl
import (
"net"
"net/http"
"sync"
"github.com/tailscale/xnet/webdav"
"tailscale.com/drive/driveimpl/shared"
)
// FileServer is a standalone WebDAV server that dynamically serves up shares.
// It's typically used in a separate process from the actual TailFS server to
// serve up files as an unprivileged user.
type FileServer struct {
l net.Listener
shareHandlers map[string]http.Handler
sharesMu sync.RWMutex
}
// NewFileServer constructs a FileServer.
//
// The server attempts to listen at a random address on 127.0.0.1.
// The listen address is available via the Addr() method.
//
// The server has to be told about shares before it can serve them. This is
// accomplished either by calling SetShares(), or locking the shares with
// LockShares(), clearing them with ClearSharesLocked(), adding them
// individually with AddShareLocked(), and finally unlocking them with
// UnlockShares().
//
// The server doesn't actually process requests until the Serve() method is
// called.
func NewFileServer() (*FileServer, error) {
// path := filepath.Join(os.TempDir(), fmt.Sprintf("%v.socket", uuid.New().String()))
// l, err := safesocket.Listen(path)
// if err != nil {
// TODO(oxtoacart): actually get safesocket working in more environments (MacOS Sandboxed, Windows, ???)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, err
}
// }
return &FileServer{
l: l,
shareHandlers: make(map[string]http.Handler),
}, nil
}
// Addr returns the address at which this FileServer is listening.
func (s *FileServer) Addr() string {
return s.l.Addr().String()
}
// Serve() starts serving files and blocks until it encounters a fatal error.
func (s *FileServer) Serve() error {
return http.Serve(s.l, s)
}
// LockShares locks the map of shares in preparation for manipulating it.
func (s *FileServer) LockShares() {
s.sharesMu.Lock()
}
// UnlockShares unlocks the map of shares.
func (s *FileServer) UnlockShares() {
s.sharesMu.Unlock()
}
// ClearSharesLocked clears the map of shares, assuming that LockShares() has
// been called first.
func (s *FileServer) ClearSharesLocked() {
s.shareHandlers = make(map[string]http.Handler)
}
// AddShareLocked adds a share to the map of shares, assuming that LockShares()
// has been called first.
func (s *FileServer) AddShareLocked(share, path string) {
s.shareHandlers[share] = &webdav.Handler{
FileSystem: &birthTimingFS{webdav.Dir(path)},
LockSystem: webdav.NewMemLS(),
}
}
// SetShares sets the full map of shares to the new value, mapping name->path.
func (s *FileServer) SetShares(shares map[string]string) {
s.LockShares()
defer s.UnlockShares()
s.ClearSharesLocked()
for name, path := range shares {
s.AddShareLocked(name, path)
}
}
// ServeHTTP implements the http.Handler interface.
func (s *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
parts := shared.CleanAndSplit(r.URL.Path)
r.URL.Path = shared.Join(parts[1:]...)
share := parts[0]
s.sharesMu.RLock()
h, found := s.shareHandlers[share]
s.sharesMu.RUnlock()
if !found {
w.WriteHeader(http.StatusNotFound)
return
}
h.ServeHTTP(w, r)
}
func (s *FileServer) Close() error {
return s.l.Close()
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package driveimpl provides an implementation of package drive.
package driveimpl
import (
"log"
"net"
"net/http"
"time"
"tailscale.com/drive"
"tailscale.com/drive/driveimpl/compositedav"
"tailscale.com/drive/driveimpl/dirfs"
"tailscale.com/types/logger"
)
const (
// statCacheTTL causes the local WebDAV proxy to cache file metadata to
// avoid excessive network roundtrips. This is similar to the
// DirectoryCacheLifetime setting of Windows' built-in SMB client,
// see https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-7/ff686200(v=ws.10)
statCacheTTL = 10 * time.Second
)
// NewFileSystemForLocal starts serving a filesystem for local clients.
// Inbound connections must be handed to HandleConn.
func NewFileSystemForLocal(logf logger.Logf) *FileSystemForLocal {
if logf == nil {
logf = log.Printf
}
fs := &FileSystemForLocal{
logf: logf,
h: &compositedav.Handler{
Logf: logf,
StatCache: &compositedav.StatCache{TTL: statCacheTTL},
},
listener: newConnListener(),
}
fs.startServing()
return fs
}
// FileSystemForLocal is the TailFS filesystem exposed to local clients. It
// provides a unified WebDAV interface to remote TailFS shares on other nodes.
type FileSystemForLocal struct {
logf logger.Logf
h *compositedav.Handler
listener *connListener
}
func (s *FileSystemForLocal) startServing() {
hs := &http.Server{Handler: s.h}
go func() {
err := hs.Serve(s.listener)
if err != nil {
// TODO(oxtoacart): should we panic or something different here?
log.Printf("serve: %v", err)
}
}()
}
// HandleConn handles connections from local WebDAV clients
func (s *FileSystemForLocal) HandleConn(conn net.Conn, remoteAddr net.Addr) error {
return s.listener.HandleConn(conn, remoteAddr)
}
// SetRemotes sets the complete set of remotes on the given tailnet domain
// using a map of name -> url. If transport is specified, that transport
// will be used to connect to these remotes.
func (s *FileSystemForLocal) SetRemotes(domain string, remotes []*drive.Remote, transport http.RoundTripper) {
children := make([]*compositedav.Child, 0, len(remotes))
for _, remote := range remotes {
children = append(children, &compositedav.Child{
Child: &dirfs.Child{
Name: remote.Name,
Available: remote.Available,
},
BaseURL: remote.URL,
Transport: transport,
})
}
s.h.SetChildren(domain, children...)
}
// Close() stops serving the WebDAV content
func (s *FileSystemForLocal) Close() error {
err := s.listener.Close()
s.h.Close()
return err
}

View File

@@ -0,0 +1,412 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package driveimpl
import (
"bufio"
"context"
"encoding/hex"
"fmt"
"log"
"math"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"os/exec"
"os/user"
"slices"
"strings"
"sync"
"time"
"github.com/tailscale/xnet/webdav"
"tailscale.com/drive"
"tailscale.com/drive/driveimpl/compositedav"
"tailscale.com/drive/driveimpl/dirfs"
"tailscale.com/drive/driveimpl/shared"
"tailscale.com/safesocket"
"tailscale.com/types/logger"
)
func NewFileSystemForRemote(logf logger.Logf) *FileSystemForRemote {
if logf == nil {
logf = log.Printf
}
fs := &FileSystemForRemote{
logf: logf,
lockSystem: webdav.NewMemLS(),
children: make(map[string]*compositedav.Child),
userServers: make(map[string]*userServer),
}
return fs
}
// FileSystemForRemote implements tailfs.FileSystemForRemote.
type FileSystemForRemote struct {
logf logger.Logf
lockSystem webdav.LockSystem
// mu guards the below values. Acquire a write lock before updating any of
// them, acquire a read lock before reading any of them.
mu sync.RWMutex
fileServerAddr string
shares []*drive.Share
children map[string]*compositedav.Child
userServers map[string]*userServer
}
// SetFileServerAddr implements tailfs.FileSystemForRemote.
func (s *FileSystemForRemote) SetFileServerAddr(addr string) {
s.mu.Lock()
s.fileServerAddr = addr
s.mu.Unlock()
}
// SetShares implements tailfs.FileSystemForRemote. Shares must be sorted
// according to tailfs.CompareShares.
func (s *FileSystemForRemote) SetShares(shares []*drive.Share) {
userServers := make(map[string]*userServer)
if drive.AllowShareAs() {
// Set up per-user server by running the current executable as an
// unprivileged user in order to avoid privilege escalation.
executable, err := os.Executable()
if err != nil {
s.logf("can't find executable: %v", err)
return
}
for _, share := range shares {
p, found := userServers[share.As]
if !found {
p = &userServer{
logf: s.logf,
username: share.As,
executable: executable,
}
userServers[share.As] = p
}
p.shares = append(p.shares, share)
}
for _, p := range userServers {
go p.runLoop()
}
}
children := make(map[string]*compositedav.Child, len(shares))
for _, share := range shares {
children[share.Name] = s.buildChild(share)
}
s.mu.Lock()
s.shares = shares
oldUserServers := s.userServers
oldChildren := s.children
s.children = children
s.userServers = userServers
s.mu.Unlock()
s.stopUserServers(oldUserServers)
s.closeChildren(oldChildren)
}
func (s *FileSystemForRemote) buildChild(share *drive.Share) *compositedav.Child {
return &compositedav.Child{
Child: &dirfs.Child{
Name: share.Name,
},
BaseURL: fmt.Sprintf("http://%v/%v", hex.EncodeToString([]byte(share.Name)), url.PathEscape(share.Name)),
Transport: &http.Transport{
Dial: func(_, shareAddr string) (net.Conn, error) {
shareNameHex, _, err := net.SplitHostPort(shareAddr)
if err != nil {
return nil, fmt.Errorf("unable to parse share address %v: %w", shareAddr, err)
}
// We had to encode the share name in hex to make sure it's a valid hostname
shareNameBytes, err := hex.DecodeString(shareNameHex)
if err != nil {
return nil, fmt.Errorf("unable to decode share name from host %v: %v", shareNameHex, err)
}
shareName := string(shareNameBytes)
s.mu.RLock()
var share *drive.Share
i, shareFound := slices.BinarySearchFunc(s.shares, shareName, func(s *drive.Share, name string) int {
return strings.Compare(s.Name, name)
})
if shareFound {
share = s.shares[i]
}
userServers := s.userServers
fileServerAddr := s.fileServerAddr
s.mu.RUnlock()
if !shareFound {
return nil, fmt.Errorf("unknown share %v", shareName)
}
var addr string
if !drive.AllowShareAs() {
addr = fileServerAddr
} else {
userServer, found := userServers[share.As]
if found {
userServer.mu.RLock()
addr = userServer.addr
userServer.mu.RUnlock()
}
}
if addr == "" {
return nil, fmt.Errorf("unable to determine address for share %v", shareName)
}
_, err = netip.ParseAddrPort(addr)
if err == nil {
// this is a regular network address, dial normally
return net.Dial("tcp", addr)
}
// assume this is a safesocket address
return safesocket.Connect(addr)
},
},
}
}
// ServeHTTPWithPerms implements tailfs.FileSystemForRemote.
func (s *FileSystemForRemote) ServeHTTPWithPerms(permissions drive.Permissions, w http.ResponseWriter, r *http.Request) {
isWrite := writeMethods[r.Method]
if isWrite {
share := shared.CleanAndSplit(r.URL.Path)[0]
switch permissions.For(share) {
case drive.PermissionNone:
// If we have no permissions to this share, treat it as not found
// to avoid leaking any information about the share's existence.
http.Error(w, "not found", http.StatusNotFound)
return
case drive.PermissionReadOnly:
http.Error(w, "permission denied", http.StatusForbidden)
return
}
}
s.mu.RLock()
childrenMap := s.children
s.mu.RUnlock()
children := make([]*compositedav.Child, 0, len(childrenMap))
// filter out shares to which the connecting principal has no access
for name, child := range childrenMap {
if permissions.For(name) == drive.PermissionNone {
continue
}
children = append(children, child)
}
h := compositedav.Handler{
Logf: s.logf,
}
h.SetChildren("", children...)
h.ServeHTTP(w, r)
}
func (s *FileSystemForRemote) stopUserServers(userServers map[string]*userServer) {
for _, server := range userServers {
if err := server.Close(); err != nil {
s.logf("error closing tailfs user server: %v", err)
}
}
}
func (s *FileSystemForRemote) closeChildren(children map[string]*compositedav.Child) {
for _, child := range children {
child.CloseIdleConnections()
}
}
// Close() implements tailfs.FileSystemForRemote.
func (s *FileSystemForRemote) Close() error {
s.mu.Lock()
userServers := s.userServers
children := s.children
s.userServers = make(map[string]*userServer)
s.children = make(map[string]*compositedav.Child)
s.mu.Unlock()
s.stopUserServers(userServers)
s.closeChildren(children)
return nil
}
// userServer runs tailscaled serve-tailfs to serve webdav content for the
// given Shares. All Shares are assumed to have the same Share.As, and the
// content is served as that Share.As user.
type userServer struct {
logf logger.Logf
shares []*drive.Share
username string
executable string
// mu guards the below values. Acquire a write lock before updating any of
// them, acquire a read lock before reading any of them.
mu sync.RWMutex
cmd *exec.Cmd
addr string
closed bool
}
func (s *userServer) Close() error {
s.mu.Lock()
cmd := s.cmd
s.closed = true
s.mu.Unlock()
if cmd != nil && cmd.Process != nil {
return cmd.Process.Kill()
}
// not running, that's okay
return nil
}
func (s *userServer) runLoop() {
maxSleepTime := 30 * time.Second
consecutiveFailures := float64(0)
var timeOfLastFailure time.Time
for {
s.mu.RLock()
closed := s.closed
s.mu.RUnlock()
if closed {
return
}
err := s.run()
now := time.Now()
timeSinceLastFailure := now.Sub(timeOfLastFailure)
timeOfLastFailure = now
if timeSinceLastFailure < maxSleepTime {
consecutiveFailures++
} else {
consecutiveFailures = 1
}
sleepTime := time.Duration(math.Pow(2, consecutiveFailures)) * time.Millisecond
if sleepTime > maxSleepTime {
sleepTime = maxSleepTime
}
s.logf("user server % v stopped with error %v, will try again in %v", s.executable, err, sleepTime)
time.Sleep(sleepTime)
}
}
// Run runs the user server using the configured executable. This function only
// works on UNIX systems, but those are the only ones on which we use
// userServers anyway.
func (s *userServer) run() error {
// set up the command
args := []string{"serve-tailfs"}
for _, s := range s.shares {
args = append(args, s.Name, s.Path)
}
var cmd *exec.Cmd
if s.canSudo() {
s.logf("starting TailFS file server as user %q", s.username)
allArgs := []string{"-n", "-u", s.username, s.executable}
allArgs = append(allArgs, args...)
cmd = exec.Command("sudo", allArgs...)
} else {
// If we were root, we should have been able to sudo as a specific
// user, but let's check just to make sure, since we never want to
// access shared folders as root.
err := s.assertNotRoot()
if err != nil {
return err
}
s.logf("starting TailFS file server as ourselves")
cmd = exec.Command(s.executable, args...)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
defer stdout.Close()
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("stderr pipe: %w", err)
}
defer stderr.Close()
err = cmd.Start()
if err != nil {
return fmt.Errorf("start: %w", err)
}
s.mu.Lock()
s.cmd = cmd
s.mu.Unlock()
// read address
stdoutScanner := bufio.NewScanner(stdout)
stdoutScanner.Scan()
if stdoutScanner.Err() != nil {
return fmt.Errorf("read addr: %w", stdoutScanner.Err())
}
addr := stdoutScanner.Text()
// send the rest of stdout and stderr to logger to avoid blocking
go func() {
for stdoutScanner.Scan() {
s.logf("tailscaled serve-tailfs stdout: %v", stdoutScanner.Text())
}
}()
stderrScanner := bufio.NewScanner(stderr)
go func() {
for stderrScanner.Scan() {
s.logf("tailscaled serve-tailfs stderr: %v", stderrScanner.Text())
}
}()
s.mu.Lock()
s.addr = strings.TrimSpace(addr)
s.mu.Unlock()
return cmd.Wait()
}
var writeMethods = map[string]bool{
"PUT": true,
"POST": true,
"COPY": true,
"LOCK": true,
"UNLOCK": true,
"MKCOL": true,
"MOVE": true,
"PROPPATCH": true,
}
// canSudo checks wether we can sudo -u the configured executable as the
// configured user by attempting to call the executable with the '-h' flag to
// print help.
func (s *userServer) canSudo() bool {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "sudo", "-n", "-u", s.username, s.executable, "-h").Run(); err != nil {
return false
}
return true
}
// assertNotRoot returns an error if the current user has UID 0 or if we cannot
// determine the current user.
//
// On Linux, root users will always have UID 0.
//
// On BSD, root users should always have UID 0.
func (s *userServer) assertNotRoot() error {
u, err := user.Current()
if err != nil {
return fmt.Errorf("assertNotRoot failed to find current user: %s", err)
}
if u.Uid == "0" {
return fmt.Errorf("%q is root", u.Name)
}
return nil
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package shared
import (
"path"
"strings"
)
// This file provides utility functions for working with URL paths. These are
// similar to functions in package path in the standard library, but differ in
// ways that are documented on the relevant functions.
const (
sepString = "/"
sepStringAndDot = "/."
sep = '/'
)
// CleanAndSplit cleans the provided path p and splits it into its constituent
// parts. This is different from path.Split which just splits a path into prefix
// and suffix.
func CleanAndSplit(p string) []string {
return strings.Split(strings.Trim(path.Clean(p), sepStringAndDot), sepString)
}
// Join behaves like path.Join() but also includes a leading slash.
func Join(parts ...string) string {
fullParts := make([]string, 0, len(parts))
fullParts = append(fullParts, sepString)
for _, part := range parts {
fullParts = append(fullParts, part)
}
return path.Join(fullParts...)
}
// IsRoot determines whether a given path p is the root path, defined as either
// empty or "/".
func IsRoot(p string) bool {
return p == "" || p == sepString
}
// Base is like path.Base except that it returns "" for the root folder
func Base(p string) string {
if IsRoot(p) {
return ""
}
return path.Base(p)
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package shared
import (
"reflect"
"testing"
)
func TestCleanAndSplit(t *testing.T) {
tests := []struct {
path string
want []string
}{
{"", []string{""}},
{"/", []string{""}},
{"//", []string{""}},
{"a", []string{"a"}},
{"/a", []string{"a"}},
{"a/", []string{"a"}},
{"/a/", []string{"a"}},
{"a/b", []string{"a", "b"}},
{"/a/b", []string{"a", "b"}},
{"a/b/", []string{"a", "b"}},
{"/a/b/", []string{"a", "b"}},
{"/a/../b", []string{"b"}},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
if got := CleanAndSplit(tt.path); !reflect.DeepEqual(tt.want, got) {
t.Errorf("CleanAndSplit(%q) = %v; want %v", tt.path, got, tt.want)
}
})
}
}
func TestJoin(t *testing.T) {
tests := []struct {
parts []string
want string
}{
{[]string{""}, "/"},
{[]string{"a"}, "/a"},
{[]string{"/a"}, "/a"},
{[]string{"/a/"}, "/a"},
{[]string{"/a/", "/b/"}, "/a/b"},
{[]string{"/a/../b", "c"}, "/b/c"},
}
for _, tt := range tests {
t.Run(Join(tt.parts...), func(t *testing.T) {
if got := Join(tt.parts...); !reflect.DeepEqual(tt.want, got) {
t.Errorf("Join(%v) = %q; want %q", tt.parts, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,111 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package shared contains types and functions shared by different tailfs
// packages.
package shared
import (
"errors"
"io"
"io/fs"
"sync"
)
// DirFile implements webdav.File for a virtual directory.
// It mimics the behavior of an os.File that is pointing at a real directory.
type DirFile struct {
// Info provides the fs.FileInfo for this directory
Info fs.FileInfo
// LoadChildren is used to load the fs.FileInfos for this directory's
// children. It is called at most once in order to support listing
// children.
LoadChildren func() ([]fs.FileInfo, error)
// loadChildrenMu guards children and loadedChildren.
loadChildrenMu sync.Mutex
children []fs.FileInfo
loadedChildren bool
}
// Readdir implements interface webdav.File. It lazily loads information about
// children when it is called.
func (d *DirFile) Readdir(count int) ([]fs.FileInfo, error) {
err := d.loadChildrenIfNecessary()
if err != nil {
return nil, err
}
if count <= 0 {
result := d.children
d.children = nil
return result, nil
}
n := len(d.children)
if count < n {
n = count
}
result := d.children[:n]
d.children = d.children[n:]
if len(d.children) == 0 {
err = io.EOF
}
return result, err
}
func (d *DirFile) loadChildrenIfNecessary() error {
d.loadChildrenMu.Lock()
defer d.loadChildrenMu.Unlock()
if !d.loadedChildren {
var err error
d.children, err = d.LoadChildren()
if err != nil {
return err
}
d.loadedChildren = true
}
return nil
}
// Stat implements interface webdav.File.
func (d *DirFile) Stat() (fs.FileInfo, error) {
return d.Info, nil
}
// Close implements interface webdav.File. It does nothing and never returns an
// error.
func (d *DirFile) Close() error {
return nil
}
// Read implements interface webdav.File. As this is a directory, it always
// fails with an fs.PathError.
func (d *DirFile) Read(b []byte) (int, error) {
return 0, &fs.PathError{
Op: "read",
Path: d.Info.Name(),
Err: errors.New("is a directory"),
}
}
// Write implements interface webdav.File. As this is a directory, it always
// fails with an fs.PathError.
func (d *DirFile) Write(b []byte) (int, error) {
return 0, &fs.PathError{
Op: "write",
Path: d.Info.Name(),
Err: errors.New("bad file descriptor"),
}
}
// Seek implements interface webdav.File. As this is a directory, it always
// fails with an fs.PathError.
func (d *DirFile) Seek(offset int64, whence int) (int64, error) {
return 0, &fs.PathError{
Op: "seek",
Path: d.Info.Name(),
Err: errors.New("invalid argument"),
}
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package shared
import (
"context"
"io/fs"
"os"
"time"
"github.com/tailscale/xnet/webdav"
)
// StaticFileInfo implements a static fs.FileInfo
type StaticFileInfo struct {
// Named controls Name()
Named string
// Sized controls Size()
Sized int64
// Moded controls Mode()
Moded os.FileMode
// BirthedTime controls BirthTime()
BirthedTime time.Time
// BirthedTimeErr stores any error encountered when trying to get BirthTime
BirthedTimeErr error
// ModdedTime controls ModTime()
ModdedTime time.Time
// Dir controls IsDir()
Dir bool
}
// BirthTime implements webdav.BirthTimer
func (fi *StaticFileInfo) BirthTime(_ context.Context) (time.Time, error) {
return fi.BirthedTime, fi.BirthedTimeErr
}
func (fi *StaticFileInfo) Name() string { return fi.Named }
func (fi *StaticFileInfo) Size() int64 { return fi.Sized }
func (fi *StaticFileInfo) Mode() os.FileMode { return fi.Moded }
func (fi *StaticFileInfo) ModTime() time.Time { return fi.ModdedTime }
func (fi *StaticFileInfo) IsDir() bool { return fi.Dir }
func (fi *StaticFileInfo) Sys() any { return nil }
func RenamedFileInfo(ctx context.Context, name string, fi fs.FileInfo) *StaticFileInfo {
var birthTime time.Time
var birthTimeErr error
birthTimer, ok := fi.(webdav.BirthTimer)
if ok {
birthTime, birthTimeErr = birthTimer.BirthTime(ctx)
}
return &StaticFileInfo{
Named: Base(name),
Sized: fi.Size(),
Moded: fi.Mode(),
BirthedTime: birthTime,
BirthedTimeErr: birthTimeErr,
ModdedTime: fi.ModTime(),
Dir: fi.IsDir(),
}
}
// ReadOnlyDirInfo returns a static fs.FileInfo for a read-only directory
func ReadOnlyDirInfo(name string, ts time.Time) *StaticFileInfo {
return &StaticFileInfo{
Named: Base(name),
Sized: 0,
Moded: 0555,
BirthedTime: ts,
ModdedTime: ts,
Dir: true,
}
}

37
drive/local.go Normal file
View File

@@ -0,0 +1,37 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package drive provides a filesystem that allows sharing folders between
// Tailscale nodes using WebDAV. The actual implementation of the core drive
// functionality lives in package driveimpl. These packages are separated to
// allow users of drive to refer to the interfaces without having a hard
// dependency on drive, so that programs which don't actually use drive can
// avoid its transitive dependencies.
package drive
import (
"net"
"net/http"
)
// Remote represents a remote TailFS node.
type Remote struct {
Name string
URL string
Available func() bool
}
// FileSystemForLocal is the TailFS filesystem exposed to local clients. It
// provides a unified WebDAV interface to remote TailFS shares on other nodes.
type FileSystemForLocal interface {
// HandleConn handles connections from local WebDAV clients
HandleConn(conn net.Conn, remoteAddr net.Addr) error
// SetRemotes sets the complete set of remotes on the given tailnet domain
// using a map of name -> url. If transport is specified, that transport
// will be used to connect to these remotes.
SetRemotes(domain string, remotes []*Remote, transport http.RoundTripper)
// Close() stops serving the WebDAV content
Close() error
}

105
drive/remote.go Normal file
View File

@@ -0,0 +1,105 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package drive
//go:generate go run tailscale.com/cmd/viewer --type=Share --clonefunc
import (
"bytes"
"net/http"
"strings"
)
var (
// DisallowShareAs forcibly disables sharing as a specific user, only used
// for testing.
DisallowShareAs = false
)
// AllowShareAs reports whether sharing files as a specific user is allowed.
func AllowShareAs() bool {
return !DisallowShareAs && doAllowShareAs()
}
// Share configures a folder to be shared through TailFS.
type Share struct {
// Name is how this share appears on remote nodes.
Name string `json:"name,omitempty"`
// Path is the path to the directory on this machine that's being shared.
Path string `json:"path,omitempty"`
// As is the UNIX or Windows username of the local account used for this
// share. File read/write permissions are enforced based on this username.
// Can be left blank to use the default value of "whoever is running the
// Tailscale GUI".
As string `json:"who,omitempty"`
// BookmarkData contains security-scoped bookmark data for the Sandboxed
// Mac application. The Sandboxed Mac application gains permission to
// access the Share's folder as a result of a user selecting it in a file
// picker. In order to retain access to it across restarts, it needs to
// hold on to a security-scoped bookmark. That bookmark is stored here. See
// https://developer.apple.com/documentation/security/app_sandbox/accessing_files_from_the_macos_app_sandbox#4144043
BookmarkData []byte `json:"bookmarkData,omitempty"`
}
func ShareViewsEqual(a, b ShareView) bool {
if !a.Valid() && !b.Valid() {
return true
}
if !a.Valid() || !b.Valid() {
return false
}
return a.Name() == b.Name() && a.Path() == b.Path() && a.As() == b.As() && a.BookmarkData().Equal(b.ж.BookmarkData)
}
func SharesEqual(a, b *Share) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return a.Name == b.Name && a.Path == b.Path && a.As == b.As && bytes.Equal(a.BookmarkData, b.BookmarkData)
}
func CompareShares(a, b *Share) int {
if a == nil && b == nil {
return 0
}
if a == nil {
return -1
}
if b == nil {
return 1
}
return strings.Compare(a.Name, b.Name)
}
// FileSystemForRemote is the TailFS filesystem exposed to remote nodes. It
// provides a unified WebDAV interface to local directories that have been
// shared.
type FileSystemForRemote interface {
// SetFileServerAddr sets the address of the file server to which we
// should proxy. This is used on platforms like Windows and MacOS
// sandboxed where we can't spawn user-specific sub-processes and instead
// rely on the UI application that's already running as an unprivileged
// user to access the filesystem for us.
SetFileServerAddr(addr string)
// SetShares sets the complete set of shares exposed by this node. If
// AllowShareAs() reports true, we will use one subprocess per user to
// access the filesystem (see userServer). Otherwise, we will use the file
// server configured via SetFileServerAddr.
SetShares(shares []*Share)
// ServeHTTPWithPerms behaves like the similar method from http.Handler but
// also accepts a Permissions map that captures the permissions of the
// connecting node.
ServeHTTPWithPerms(permissions Permissions, w http.ResponseWriter, r *http.Request)
// Close() stops serving the WebDAV content
Close() error
}

13
drive/remote_nonunix.go Normal file
View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !unix
package drive
func doAllowShareAs() bool {
// On non-UNIX platforms, we use the GUI application (e.g. Windows taskbar
// icon) to access the filesystem as whatever unprivileged user is running
// the GUI app, so we cannot allow sharing as a different user.
return false
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package drive
import (
"encoding/json"
"fmt"
)
type Permission uint8
const (
PermissionNone Permission = iota
PermissionReadOnly
PermissionReadWrite
)
const (
accessReadOnly = "ro"
accessReadWrite = "rw"
wildcardShare = "*"
)
// Permissions represents the set of permissions for a given principal to a
// set of shares.
type Permissions map[string]Permission
type grant struct {
Shares []string
Access string
}
// ParsePermissions builds a Permissions map from a lis of raw grants.
func ParsePermissions(rawGrants [][]byte) (Permissions, error) {
permissions := make(Permissions)
for _, rawGrant := range rawGrants {
var g grant
err := json.Unmarshal(rawGrant, &g)
if err != nil {
return nil, fmt.Errorf("unmarshal raw grants: %v", err)
}
for _, share := range g.Shares {
existingPermission := permissions[share]
permission := PermissionReadOnly
if g.Access == accessReadWrite {
permission = PermissionReadWrite
}
if permission > existingPermission {
permissions[share] = permission
}
}
}
return permissions, nil
}
func (p Permissions) For(share string) Permission {
specific := p[share]
wildcard := p[wildcardShare]
if specific > wildcard {
return specific
}
return wildcard
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package drive
import (
"encoding/json"
"testing"
)
func TestPermissions(t *testing.T) {
tests := []struct {
perms []grant
share string
want Permission
}{
{[]grant{
{Shares: []string{"*"}, Access: "ro"},
{Shares: []string{"a"}, Access: "rw"},
},
"a",
PermissionReadWrite,
},
{[]grant{
{Shares: []string{"*"}, Access: "ro"},
{Shares: []string{"a"}, Access: "rw"},
},
"b",
PermissionReadOnly,
},
{[]grant{
{Shares: []string{"a"}, Access: "rw"},
},
"c",
PermissionNone,
},
}
for _, tt := range tests {
t.Run(tt.share, func(t *testing.T) {
var rawPerms [][]byte
for _, perm := range tt.perms {
b, err := json.Marshal(perm)
if err != nil {
t.Fatal(err)
}
rawPerms = append(rawPerms, b)
}
p, err := ParsePermissions(rawPerms)
if err != nil {
t.Fatal(err)
}
got := p.For(tt.share)
if got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}

16
drive/remote_unix.go Normal file
View File

@@ -0,0 +1,16 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build unix
package drive
import "tailscale.com/version"
func doAllowShareAs() bool {
// All UNIX platforms use user servers (sub-processes) to access the OS
// filesystem as a specific unprivileged users, except for sandboxed macOS
// which doesn't support impersonating users and instead accesses files
// through the macOS GUI app as whatever unprivileged user is running it.
return !version.IsSandboxedMacOS()
}