WIP: WebDAV server

This commit is contained in:
Alexander Neumann 2018-01-15 11:44:06 +01:00
parent c7d789ab04
commit eefeb387d9
6 changed files with 557 additions and 0 deletions

96
cmd/restic/cmd_webdav.go Normal file
View File

@ -0,0 +1,96 @@
// +build !openbsd
// +build !windows
package main
import (
"log"
"net/http"
"os"
"time"
"github.com/spf13/cobra"
"github.com/restic/restic/internal/errors"
"github.com/restic/restic/internal/restic"
"github.com/restic/restic/internal/serve"
)
var cmdWebDAV = &cobra.Command{
Use: "webdav [flags]",
Short: "runs a WebDAV server for the repository",
Long: `
The webdav command runs a WebDAV server for the reposiotry that you can then access via a WebDAV client.
`,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runWebDAV(webdavOptions, globalOptions, args)
},
}
// WebDAVOptions collects all options for the webdav command.
type WebDAVOptions struct {
Listen string
Host string
Tags restic.TagLists
Paths []string
}
var webdavOptions WebDAVOptions
func init() {
cmdRoot.AddCommand(cmdWebDAV)
webdavFlags := cmdWebDAV.Flags()
webdavFlags.StringVarP(&webdavOptions.Listen, "listen", "l", "localhost:3080", "set the listen host name and `address`")
webdavFlags.StringVarP(&mountOptions.Host, "host", "H", "", `only consider snapshots for this host`)
webdavFlags.Var(&mountOptions.Tags, "tag", "only consider snapshots which include this `taglist`")
webdavFlags.StringArrayVar(&mountOptions.Paths, "path", nil, "only consider snapshots which include this (absolute) `path`")
}
func runWebDAV(opts WebDAVOptions, gopts GlobalOptions, args []string) error {
if len(args) > 0 {
return errors.Fatal("this command does not accept additional arguments")
}
repo, err := OpenRepository(gopts)
if err != nil {
return err
}
lock, err := lockRepo(repo)
defer unlockRepo(lock)
if err != nil {
return err
}
err = repo.LoadIndex(gopts.ctx)
if err != nil {
return err
}
errorLogger := log.New(os.Stderr, "error log: ", log.Flags())
cfg := serve.Config{
Host: opts.Host,
Tags: opts.Tags,
Paths: opts.Paths,
}
h, err := serve.NewWebDAV(gopts.ctx, repo, cfg)
if err != nil {
return err
}
srv := &http.Server{
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
Addr: opts.Listen,
Handler: h,
ErrorLog: errorLogger,
}
return srv.ListenAndServe()
}

74
internal/serve/dir.go Normal file
View File

@ -0,0 +1,74 @@
package serve
import (
"io"
"os"
"github.com/restic/restic/internal/debug"
"github.com/restic/restic/internal/restic"
"golang.org/x/net/webdav"
)
// RepoDir implements a read-only directory from a repository.
type RepoDir struct {
fi os.FileInfo
nodes []*restic.Node
}
// statically ensure that RepoDir implements webdav.File
var _ webdav.File = &RepoDir{}
func (f *RepoDir) Write(p []byte) (int, error) {
return 0, webdav.ErrForbidden
}
// Close closes the repo file.
func (f *RepoDir) Close() error {
debug.Log("")
return nil
}
// Read reads up to len(p) byte from the file.
func (f *RepoDir) Read(p []byte) (int, error) {
debug.Log("")
return 0, io.EOF
}
// Seek sets the offset for the next Read or Write to offset, interpreted
// according to whence: SeekStart means relative to the start of the file,
// SeekCurrent means relative to the current offset, and SeekEnd means relative
// to the end. Seek returns the new offset relative to the start of the file
// and an error, if any.
func (f *RepoDir) Seek(offset int64, whence int) (int64, error) {
debug.Log("")
return 0, webdav.ErrForbidden
}
// Readdir reads the contents of the directory associated with file and returns
// a slice of up to n FileInfo values, as would be returned by Lstat, in
// directory order. Subsequent calls on the same file will yield further
// FileInfos.
//
// If n > 0, Readdir returns at most n FileInfo structures. In this case, if
// Readdir returns an empty slice, it will return a non-nil error explaining
// why. At the end of a directory, the error is io.EOF.
//
// If n <= 0, Readdir returns all the FileInfo from the directory in a single
// slice. In this case, if Readdir succeeds (reads all the way to the end of
// the directory), it returns the slice and a nil error. If it encounters an
// error before the end of the directory, Readdir returns the FileInfo read
// until that point and a non-nil error.
func (f *RepoDir) Readdir(count int) (entries []os.FileInfo, err error) {
debug.Log("count %d, %d nodes", count, len(f.nodes))
entries = make([]os.FileInfo, 0, len(f.nodes))
for _, node := range f.nodes {
entries = append(entries, fileInfoFromNode(node))
}
return entries, nil
}
// Stat returns a FileInfo describing the named file.
func (f *RepoDir) Stat() (os.FileInfo, error) {
return f.fi, nil
}

67
internal/serve/file.go Normal file
View File

@ -0,0 +1,67 @@
package serve
import (
"io"
"os"
"github.com/restic/restic/internal/restic"
"golang.org/x/net/webdav"
)
// RepoFile implements a read-only directory from a repository.
type RepoFile struct {
fi os.FileInfo
node *restic.Node
}
// statically ensure that RepoFile implements webdav.File
var _ webdav.File = &RepoFile{}
func (f *RepoFile) Write(p []byte) (int, error) {
return 0, webdav.ErrForbidden
}
// Close closes the repo file.
func (f *RepoFile) Close() error {
return nil
}
// Read reads up to len(p) byte from the file.
func (f *RepoFile) Read(p []byte) (int, error) {
// TODO
return 0, io.EOF
}
// Seek sets the offset for the next Read or Write to offset, interpreted
// according to whence: SeekStart means relative to the start of the file,
// SeekCurrent means relative to the current offset, and SeekEnd means relative
// to the end. Seek returns the new offset relative to the start of the file
// and an error, if any.
func (f *RepoFile) Seek(offset int64, whence int) (int64, error) {
// TODO
return 0, io.EOF
}
// Readdir reads the contents of the directory associated with file and returns
// a slice of up to n FileInfo values, as would be returned by Lstat, in
// directory order. Subsequent calls on the same file will yield further
// FileInfos.
//
// If n > 0, Readdir returns at most n FileInfo structures. In this case, if
// Readdir returns an empty slice, it will return a non-nil error explaining
// why. At the end of a directory, the error is io.EOF.
//
// If n <= 0, Readdir returns all the FileInfo from the directory in a single
// slice. In this case, if Readdir succeeds (reads all the way to the end of
// the directory), it returns the slice and a nil error. If it encounters an
// error before the end of the directory, Readdir returns the FileInfo read
// until that point and a non-nil error.
func (f *RepoFile) Readdir(count int) ([]os.FileInfo, error) {
// TODO
return nil, io.EOF
}
// Stat returns a FileInfo describing the named file.
func (f *RepoFile) Stat() (os.FileInfo, error) {
return f.fi, nil
}

View File

@ -0,0 +1,43 @@
package serve
import (
"os"
"time"
"github.com/restic/restic/internal/restic"
)
// virtFileInfo is used to construct an os.FileInfo for a server.
type virtFileInfo struct {
name string
size int64
mode os.FileMode
modtime time.Time
isdir bool
}
// statically ensure that virtFileInfo implements os.FileInfo.
var _ os.FileInfo = virtFileInfo{}
func (fi virtFileInfo) Name() string { return fi.name }
func (fi virtFileInfo) Size() int64 { return fi.size }
func (fi virtFileInfo) Mode() os.FileMode { return fi.mode }
func (fi virtFileInfo) ModTime() time.Time { return fi.modtime }
func (fi virtFileInfo) IsDir() bool { return fi.isdir }
func (fi virtFileInfo) Sys() interface{} { return nil }
func fileInfoFromNode(node *restic.Node) os.FileInfo {
fi := virtFileInfo{
name: node.Name,
size: int64(node.Size),
mode: node.Mode,
modtime: node.ModTime,
}
if node.Type == "dir" {
fi.isdir = true
fi.mode |= os.ModeDir
}
return fi
}

231
internal/serve/fs.go Normal file
View File

@ -0,0 +1,231 @@
package serve
import (
"context"
"os"
"path"
"sync"
"time"
"github.com/restic/restic/internal/errors"
"github.com/restic/restic/internal/debug"
"github.com/restic/restic/internal/restic"
"golang.org/x/net/webdav"
)
// Config holds settings for the file system served.
type Config struct {
Host string
Tags []restic.TagList
Paths []string
}
const snapshotFormat = "2006-01-02_150405"
// RepoFileSystem implements a read-only file system on top of a repositoy.
type RepoFileSystem struct {
repo restic.Repository
lastCheck time.Time
entries map[string]webdav.File
m sync.Mutex
}
// NewRepoFileSystem returns a new file system for the repo.
func NewRepoFileSystem(ctx context.Context, repo restic.Repository, cfg Config) (*RepoFileSystem, error) {
snapshots := restic.FindFilteredSnapshots(ctx, repo, cfg.Host, cfg.Tags, cfg.Paths)
lastcheck := time.Now()
nodes := make([]*restic.Node, 0, len(snapshots))
entries := make(map[string]webdav.File)
for _, sn := range snapshots {
name := sn.Time.Format(snapshotFormat)
snFileInfo := virtFileInfo{
name: name,
size: 0,
mode: 0755 | os.ModeDir,
modtime: sn.Time,
isdir: true,
}
if sn.Tree == nil {
return nil, errors.Errorf("snapshot %v has nil tree", sn.ID().Str())
}
tree, err := repo.LoadTree(ctx, *sn.Tree)
if err != nil {
return nil, err
}
p := path.Join("/", name)
entries[p] = &RepoDir{
fi: snFileInfo,
nodes: tree.Nodes,
}
nodes = append(nodes, &restic.Node{
Name: name,
Type: "dir",
})
}
entries["/"] = &RepoDir{
nodes: nodes,
fi: virtFileInfo{
name: "root",
size: 0,
mode: 0755 | os.ModeDir,
modtime: lastcheck,
isdir: true,
},
}
fs := &RepoFileSystem{
repo: repo,
lastCheck: lastcheck,
entries: entries,
}
return fs, nil
}
// statically ensure that RepoFileSystem implements webdav.FileSystem
var _ webdav.FileSystem = &RepoFileSystem{}
// Mkdir creates a new directory, it is not available for RepoFileSystem.
func (fs *RepoFileSystem) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
return webdav.ErrForbidden
}
func (fs *RepoFileSystem) loadPath(ctx context.Context, name string) error {
debug.Log("%v", name)
fs.m.Lock()
_, ok := fs.entries[name]
fs.m.Unlock()
if ok {
return nil
}
dirname := path.Dir(name)
if dirname == "/" {
return nil
}
err := fs.loadPath(ctx, dirname)
if err != nil {
return err
}
entry, ok := fs.entries[dirname]
if !ok {
// loadPath did not succeed
return nil
}
repodir, ok := entry.(*RepoDir)
if !ok {
return nil
}
filename := path.Base(name)
for _, node := range repodir.nodes {
if node.Name != filename {
continue
}
debug.Log("found item %v :%v", filename, node)
switch node.Type {
case "dir":
if node.Subtree == nil {
return errors.Errorf("tree %v has nil tree", dirname)
}
tree, err := fs.repo.LoadTree(ctx, *node.Subtree)
if err != nil {
return err
}
newEntry := &RepoDir{
fi: fileInfoFromNode(node),
nodes: tree.Nodes,
}
fs.m.Lock()
fs.entries[name] = newEntry
fs.m.Unlock()
case "file":
newEntry := &RepoFile{
fi: fileInfoFromNode(node),
node: node,
}
fs.m.Lock()
fs.entries[name] = newEntry
fs.m.Unlock()
}
return nil
}
return nil
}
// OpenFile opens a file for reading.
func (fs *RepoFileSystem) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
name = path.Clean(name)
debug.Log("%v", name)
if flag != os.O_RDONLY {
return nil, webdav.ErrForbidden
}
err := fs.loadPath(ctx, name)
if err != nil {
return nil, err
}
fs.m.Lock()
entry, ok := fs.entries[name]
fs.m.Unlock()
if !ok {
return nil, os.ErrNotExist
}
return entry, nil
}
// RemoveAll recursively removes files and directories, it is not available for RepoFileSystem.
func (fs *RepoFileSystem) RemoveAll(ctx context.Context, name string) error {
debug.Log("%v", name)
return webdav.ErrForbidden
}
// Rename renames files or directories, it is not available for RepoFileSystem.
func (fs *RepoFileSystem) Rename(ctx context.Context, oldName, newName string) error {
debug.Log("%v -> %v", oldName, newName)
return webdav.ErrForbidden
}
// Stat returns information on a file or directory.
func (fs *RepoFileSystem) Stat(ctx context.Context, name string) (os.FileInfo, error) {
name = path.Clean(name)
err := fs.loadPath(ctx, name)
if err != nil {
return nil, err
}
fs.m.Lock()
entry, ok := fs.entries[name]
fs.m.Unlock()
if !ok {
debug.Log("%v not found", name)
return nil, os.ErrNotExist
}
fi, err := entry.Stat()
debug.Log("%v %v", name, fi)
return fi, err
}

46
internal/serve/webdav.go Normal file
View File

@ -0,0 +1,46 @@
package serve
import (
"context"
"log"
"net/http"
"os"
"github.com/restic/restic/internal/restic"
"golang.org/x/net/webdav"
)
// WebDAV implements a WebDAV handler on the repo.
type WebDAV struct {
restic.Repository
webdav.Handler
}
var logger = log.New(os.Stderr, "webdav log: ", log.Flags())
func logRequest(req *http.Request, err error) {
logger.Printf("req %v %v -> %v\n", req.Method, req.URL.Path, err)
}
// NewWebDAV returns a new *WebDAV which allows serving the repo via WebDAV.
func NewWebDAV(ctx context.Context, repo restic.Repository, cfg Config) (*WebDAV, error) {
fs, err := NewRepoFileSystem(ctx, repo, cfg)
if err != nil {
return nil, err
}
wd := &WebDAV{
Repository: repo,
Handler: webdav.Handler{
FileSystem: fs,
LockSystem: webdav.NewMemLS(),
Logger: logRequest,
},
}
return wd, nil
}
func (srv *WebDAV) ServeHTTP(res http.ResponseWriter, req *http.Request) {
logger.Printf("handle %v %v\n", req.Method, req.URL.Path)
srv.Handler.ServeHTTP(res, req)
}