tailscale/tailfs/webdavfs/webdavfs.go
Percy Wegmann 993acf4475 tailfs: initial implementation
Add a WebDAV-based folder sharing mechanism that is exposed to local clients at
100.100.100.100:8080 and to remote peers via a new peerapi endpoint at
/v0/tailfs.

Add the ability to manage folder sharing via the new 'share' CLI sub-command.

Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-02-09 09:13:51 -06:00

257 lines
6.6 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package webdavfs provides an implementation of webdav.FileSystem backed by
// a gowebdav.Client.
package webdavfs
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"time"
"github.com/tailscale/gowebdav"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/shared"
"tailscale.com/tstime"
"tailscale.com/types/logger"
)
const (
// keep requests from taking too long if the server is down or slow to respond
opTimeout = 2 * time.Second // TODO(oxtoacart): tune this
)
type Options struct {
// Logf us a logging function to use for debug and error logging.
Logf logger.Logf
// URL is the base URL of the remote WebDAV server.
URL string
// Transport is the http.Transport to use for connecting to the WebDAV
// server.
Transport http.RoundTripper
// StatRoot, if true, will cause this filesystem to actually stat its own
// root via the remote server. If false, it will use a static directory
// info for the root to avoid a round-trip.
StatRoot bool
// StatCacheTTL, when greater than 0, enables caching of file metadata
StatCacheTTL time.Duration
// Clock, if specified, determines the current time. If not specified, we
// default to time.Now().
Clock tstime.Clock
}
// webdavFS adapts gowebdav.Client to webdav.FileSystem
type webdavFS struct {
logf logger.Logf
transport http.RoundTripper
*gowebdav.Client
now func() time.Time
statRoot bool
statCache *statCache
}
// New creates a new webdav.FileSystem backed by the given gowebdav.Client.
// If cacheTTL is greater than zero, the filesystem will cache results from
// Stat calls for the given duration.
func New(opts Options) webdav.FileSystem {
if opts.Logf == nil {
opts.Logf = log.Printf
}
wfs := &webdavFS{
logf: opts.Logf,
transport: opts.Transport,
Client: gowebdav.New(&gowebdav.Opts{URI: opts.URL, Transport: opts.Transport}),
statRoot: opts.StatRoot,
}
if opts.StatCacheTTL > 0 {
wfs.statCache = newStatCache(opts.StatCacheTTL)
}
if opts.Clock != nil {
wfs.now = opts.Clock.Now
} else {
wfs.now = time.Now
}
return wfs
}
// Mkdir implements webdav.FileSystem.
func (wfs *webdavFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
return translateWebDAVError(wfs.Client.Mkdir(ctxWithTimeout, name, perm))
}
// OpenFile implements webdav.FileSystem.
func (wfs *webdavFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
if hasFlag(flag, os.O_APPEND) {
return nil, &os.PathError{
Op: "open",
Path: name,
Err: errors.New("mode APPEND not supported"),
}
}
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if hasFlag(flag, os.O_WRONLY) || hasFlag(flag, os.O_RDWR) {
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
fi, err := wfs.Stat(ctxWithTimeout, name)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err == nil && fi.IsDir() {
return nil, &os.PathError{
Op: "open",
Path: name,
Err: errors.New("is a directory"),
}
}
pipeReader, pipeWriter := io.Pipe()
f := &writeOnlyFile{
WriteCloser: pipeWriter,
name: name,
perm: perm,
fs: wfs,
finalError: make(chan error, 1),
}
go func() {
defer pipeReader.Close()
err := wfs.Client.WriteStream(context.Background(), name, pipeReader, perm)
f.finalError <- err
close(f.finalError)
}()
return f, nil
}
// Assume reading
fi, err := wfs.Stat(ctxWithTimeout, name)
if err != nil {
return nil, translateWebDAVError(err)
}
if fi.IsDir() {
return wfs.dirWithChildren(name, fi), nil
}
return &readOnlyFile{
client: wfs.Client,
name: name,
initialFI: fi,
rewindBuffer: make([]byte, 0, MaxRewindBuffer),
}, nil
}
func (wfs *webdavFS) dirWithChildren(name string, fi fs.FileInfo) webdav.File {
return &shared.DirFile{
Info: fi,
LoadChildren: func() ([]fs.FileInfo, error) {
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
dirInfos, err := wfs.Client.ReadDir(ctxWithTimeout, name)
if err != nil {
wfs.logf("encountered error reading children of '%v', returning empty list: %v", name, err)
// We do not return the actual error here because some WebDAV clients
// will take that as an invitation to retry, hanging in the process.
return dirInfos, nil
}
if wfs.statCache != nil {
wfs.statCache.set(name, dirInfos)
}
return dirInfos, nil
},
}
}
// RemoveAll implements webdav.FileSystem.
func (wfs *webdavFS) RemoveAll(ctx context.Context, name string) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
return wfs.Client.RemoveAll(ctxWithTimeout, name)
}
// Rename implements webdav.FileSystem.
func (wfs *webdavFS) Rename(ctx context.Context, oldName, newName string) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
return wfs.Client.Rename(ctxWithTimeout, oldName, newName, false)
}
// Stat implements webdav.FileSystem.
func (wfs *webdavFS) Stat(ctx context.Context, name string) (fs.FileInfo, error) {
if wfs.statCache != nil {
return wfs.statCache.getOrFetch(name, wfs.doStat)
}
return wfs.doStat(name)
}
// Close implements webdav.FileSystem.
func (wfs *webdavFS) Close() error {
if wfs.statCache != nil {
wfs.statCache.stop()
}
tr, ok := wfs.transport.(*http.Transport)
if ok {
tr.CloseIdleConnections()
}
return nil
}
func (wfs *webdavFS) doStat(name string) (fs.FileInfo, error) {
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if !wfs.statRoot && shared.IsRoot(name) {
// use a static directory info for the root
// always use now() as the modified time to bust caches
return shared.ReadOnlyDirInfo(name, wfs.now()), nil
}
fi, err := wfs.Client.Stat(ctxWithTimeout, name)
return fi, translateWebDAVError(err)
}
func translateWebDAVError(err error) error {
if err == nil {
return nil
}
var se gowebdav.StatusError
if errors.As(err, &se) {
if se.Status == http.StatusNotFound {
return os.ErrNotExist
}
}
// Note, we intentionally don't wrap the error because we don't want
// github.com/tailscale/xnet/webdav to try to interpret the underlying
// error.
return fmt.Errorf("unexpected WebDAV error: %v", err)
}
func hasFlag(flags int, flag int) bool {
return (flags & flag) == flag
}