mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-26 19:45:35 +00:00
993acf4475
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>
257 lines
6.6 KiB
Go
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
|
|
}
|