diff --git a/cmd/restic/cmd_webdav.go b/cmd/restic/cmd_webdav.go new file mode 100644 index 000000000..e48b82fe4 --- /dev/null +++ b/cmd/restic/cmd_webdav.go @@ -0,0 +1,98 @@ +package main + +import ( + "log" + "net/http" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/restic/restic/internal/errors" + "github.com/restic/restic/internal/fuse" + "github.com/restic/restic/internal/restic" + "github.com/restic/restic/internal/webdav" +) + +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 + + Hosts []string + Tags restic.TagLists + Paths []string + SnapshotTemplate 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.StringArrayVarP(&webdavOptions.Hosts, "host", "H", nil, `only consider snapshots for this host (can be specified multiple times)`) + webdavFlags.Var(&webdavOptions.Tags, "tag", "only consider snapshots which include this `taglist`") + webdavFlags.StringArrayVar(&webdavOptions.Paths, "path", nil, "only consider snapshots which include this (absolute) `path`") + webdavFlags.StringVar(&webdavOptions.SnapshotTemplate, "snapshot-template", time.RFC3339, "set `template` to use for snapshot dirs") +} + +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 := fuse.Config{ + Hosts: opts.Hosts, + Tags: opts.Tags, + Paths: opts.Paths, + SnapshotTemplate: opts.SnapshotTemplate, + } + root := fuse.NewRoot(repo, cfg) + + h, err := webdav.NewWebDAV(gopts.ctx, root) + 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() +} diff --git a/internal/webdav/dir.go b/internal/webdav/dir.go new file mode 100644 index 000000000..434bf9e4d --- /dev/null +++ b/internal/webdav/dir.go @@ -0,0 +1,103 @@ +package webdav + +import ( + "context" + "io" + "os" + + "bazil.org/fuse" + "bazil.org/fuse/fs" + "github.com/restic/restic/internal/debug" + "golang.org/x/net/webdav" +) + +type fuseDir interface { + fs.Node + fs.HandleReadDirAller + fs.NodeStringLookuper +} + +// RepoDir implements a read-only directory +type RepoDir struct { + name string + dir fuseDir + ctx context.Context +} + +// 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("Close %v", f.name) + 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("Seek %v", f.name) + 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("Readdir %v, count %d", f.name, count) + + dirent, err := f.dir.ReadDirAll(f.ctx) + entries = make([]os.FileInfo, 0, len(dirent)) + for _, node := range dirent { + if node.Name == "." || node.Name == ".." { + continue + } + + fsNode, err := f.dir.Lookup(f.ctx, node.Name) + if err != nil { + return nil, err + } + + var attr fuse.Attr + err = fsNode.Attr(f.ctx, &attr) + if err != nil { + return nil, err + } + + entries = append(entries, fileInfoFromAttr(node.Name, attr)) + } + return entries, nil +} + +// Stat returns a FileInfo describing the named file. +func (f *RepoDir) Stat() (os.FileInfo, error) { + debug.Log("Stat %v", f.name) + var attr fuse.Attr + f.dir.Attr(f.ctx, &attr) + + return fileInfoFromAttr(f.name, attr), nil +} diff --git a/internal/webdav/file.go b/internal/webdav/file.go new file mode 100644 index 000000000..2e92c8440 --- /dev/null +++ b/internal/webdav/file.go @@ -0,0 +1,106 @@ +package webdav + +import ( + "context" + "io" + "os" + + "bazil.org/fuse" + "bazil.org/fuse/fs" + "github.com/restic/restic/internal/debug" + "golang.org/x/net/webdav" +) + +type fuseFile interface { + fs.Node + fs.NodeOpener +} + +// RepoFile implements a read-only file +type RepoFile struct { + name string + file fuseFile + handle fs.HandleReader + seek int64 + size int64 + ctx context.Context +} + +// 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 { + debug.Log("Close %v", f.name) + return nil +} + +// Read reads up to len(p) byte from the file. +func (f *RepoFile) Read(p []byte) (int, error) { + debug.Log("Read %v, count %d", f.name, len(p)) + var err error + + if f.handle == nil { + h, err := f.file.Open(f.ctx, nil, nil) + if err != nil { + return 0, err + } + f.handle = h.(fs.HandleReader) + } + + maxread := int(f.size - f.seek) + if len(p) < maxread { + maxread = len(p) + } + if maxread <= 0 { + return 0, io.EOF + } + req := &fuse.ReadRequest{Size: maxread, Offset: f.seek} + resp := &fuse.ReadResponse{Data: p} + err = f.handle.Read(f.ctx, req, resp) + if err != nil { + return 0, err + } + f.seek += int64(len(resp.Data)) + + return len(resp.Data), nil +} + +// 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) { + debug.Log("Seek %v, offset: %d", f.name, offset) + switch whence { + case os.SEEK_SET: + f.seek = offset + case os.SEEK_CUR: + f.seek += offset + case os.SEEK_END: + f.seek = f.size + offset + } + if f.seek < 0 || f.seek > f.size { + return 0, io.EOF + } + + return f.seek, nil +} + +func (f *RepoFile) Readdir(count int) ([]os.FileInfo, error) { + return nil, io.EOF +} + +// Stat returns a FileInfo describing the named file. +func (f *RepoFile) Stat() (os.FileInfo, error) { + debug.Log("Stat %v", f.name) + var attr fuse.Attr + f.file.Attr(f.ctx, &attr) + + return fileInfoFromAttr(f.name, attr), nil +} diff --git a/internal/webdav/fileinfo.go b/internal/webdav/fileinfo.go new file mode 100644 index 000000000..375c29454 --- /dev/null +++ b/internal/webdav/fileinfo.go @@ -0,0 +1,39 @@ +package webdav + +import ( + "os" + "time" + + "bazil.org/fuse" +) + +// 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 fileInfoFromAttr(name string, attr fuse.Attr) os.FileInfo { + fi := virtFileInfo{ + name: name, + size: int64(attr.Size), + mode: attr.Mode, + modtime: attr.Mtime, + isdir: (attr.Mode & os.ModeDir) != 0, + } + + return fi +} diff --git a/internal/webdav/fs.go b/internal/webdav/fs.go new file mode 100644 index 000000000..02dd92c3d --- /dev/null +++ b/internal/webdav/fs.go @@ -0,0 +1,102 @@ +package webdav + +import ( + "context" + "os" + "path" + "strings" + + "bazil.org/fuse" + fusefs "bazil.org/fuse/fs" + "github.com/restic/restic/internal/debug" + "golang.org/x/net/webdav" +) + +// RepoFileSystem implements a read-only file system on top of a repositoy. +type RepoFileSystem struct { + ctx context.Context + root fuseDir +} + +// 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 +} + +// RemoveAll recursively removes files and directories, it is not available for RepoFileSystem. +func (fs *RepoFileSystem) RemoveAll(ctx context.Context, name string) error { + 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 { + return webdav.ErrForbidden +} + +// open opens a file. +func (fs *RepoFileSystem) open(ctx context.Context, name string) (webdav.File, error) { + var err error + + name = path.Clean(name) + parts := strings.Split(name, "/") + + node := fs.root.(fusefs.Node) + + for _, part := range parts { + if part == "." || part == "" { + continue + } + + // if there is a part left, the actual node must be a dir + nodedir, ok := node.(fuseDir) + if !ok { + // didn't get a dir + return nil, os.ErrNotExist + } + + node, err = nodedir.Lookup(fs.ctx, part) + if err != nil { + if err == fuse.ENOENT { + return nil, os.ErrNotExist + } + return nil, err + } + } + + var attr fuse.Attr + err = node.Attr(fs.ctx, &attr) + if err != nil { + return nil, err + } + + switch { + case attr.Mode&os.ModeDir != 0: // dir + return &RepoDir{ctx: fs.ctx, dir: node.(fuseDir), name: name}, nil + case attr.Mode&os.ModeType == 0: // file + return &RepoFile{ctx: fs.ctx, file: node.(fuseFile), name: name, size: int64(attr.Size)}, nil + } + + return &RepoLink{name: name}, nil + +} + +// OpenFile opens a file for reading. +func (fs *RepoFileSystem) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) { + debug.Log("Open %v", name) + if flag != os.O_RDONLY { + return nil, webdav.ErrForbidden + } + return fs.open(ctx, name) +} + +// Stat returns information on a file or directory. +func (fs *RepoFileSystem) Stat(ctx context.Context, name string) (os.FileInfo, error) { + debug.Log("Stat %v", name) + + file, err := fs.open(fs.ctx, name) + if err != nil { + return nil, err + } + + return file.Stat() +} diff --git a/internal/webdav/link.go b/internal/webdav/link.go new file mode 100644 index 000000000..7d512575b --- /dev/null +++ b/internal/webdav/link.go @@ -0,0 +1,52 @@ +package webdav + +import ( + "io" + "os" + + "github.com/restic/restic/internal/debug" + "golang.org/x/net/webdav" +) + +// RepoFile implements a link. +// Actually no implementation; this will appear as a zero-sized file. +type RepoLink struct { + name string +} + +// statically ensure that RepoFile implements webdav.File +var _ webdav.File = &RepoLink{} + +func (f *RepoLink) Write(p []byte) (int, error) { + return 0, webdav.ErrForbidden +} + +func (f *RepoLink) Close() error { + debug.Log("Close %v", f.name) + return nil +} + +// Read reads up to len(p) byte from the file. +func (f *RepoLink) Read(p []byte) (int, error) { + debug.Log("Read %v, count %d", f.name, len(p)) + return 0, io.EOF +} + +func (f *RepoLink) Seek(offset int64, whence int) (int64, error) { + debug.Log("Seek %v, offset: %d", f.name, offset) + return 0, io.EOF +} + +func (f *RepoLink) Readdir(count int) ([]os.FileInfo, error) { + return nil, io.EOF +} + +// Stat returns a FileInfo describing the named file. +func (f *RepoLink) Stat() (os.FileInfo, error) { + debug.Log("Stat %v", f.name) + return &virtFileInfo{ + name: f.name, + size: 0, + isdir: false, + }, nil +} diff --git a/internal/webdav/webdav.go b/internal/webdav/webdav.go new file mode 100644 index 000000000..e7762768b --- /dev/null +++ b/internal/webdav/webdav.go @@ -0,0 +1,39 @@ +package webdav + +import ( + "context" + "log" + "net/http" + "os" + + "golang.org/x/net/webdav" +) + +// WebDAV implements a WebDAV handler on the repo. +type WebDAV struct { + 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, root fuseDir) (*WebDAV, error) { + fs := &RepoFileSystem{ctx: ctx, root: root} + wd := &WebDAV{ + 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) +}