From 25845f5126d97dfa924d417a62917c9a74df9ff9 Mon Sep 17 00:00:00 2001 From: Will Rouesnel Date: Mon, 7 Nov 2016 02:41:22 +1100 Subject: [PATCH] Add WebDAV support via the `restic web` command. Adds support for mounting and browsing a restic repository as a webdav endpoint. Known Issues: * symlinks in the source snapshot are ignored, because WebDAV does not support symlinking. This gives the most sensible behavior if one were copying files from a WebDAV mountpoint to an existing filesystem by not overwriting links with files. --- src/cmds/restic/cmd_web.go | 91 +++++----------- src/restic/webdav/dir.go | 111 ++++++++++++++++++++ src/restic/webdav/file.go | 153 +++++++++++++++++++++++++++ src/restic/webdav/fileinfo.go | 39 +++++++ src/restic/webdav/link.go | 85 +++++++++++++++ src/restic/webdav/webdav.go | 188 ++++++++++++++++++++++++++++++++++ 6 files changed, 602 insertions(+), 65 deletions(-) create mode 100644 src/restic/webdav/dir.go create mode 100644 src/restic/webdav/file.go create mode 100644 src/restic/webdav/fileinfo.go create mode 100644 src/restic/webdav/link.go diff --git a/src/cmds/restic/cmd_web.go b/src/cmds/restic/cmd_web.go index e339e5ecf..c2f520cc3 100644 --- a/src/cmds/restic/cmd_web.go +++ b/src/cmds/restic/cmd_web.go @@ -4,48 +4,44 @@ package main import ( - "os" + "net/http" "github.com/spf13/cobra" "restic/debug" "restic/errors" - resticfs "restic/fs" + //resticfs "restic/fs" "restic/fuse" + resticWebdav "restic/webdav" - systemFuse "bazil.org/fuse" - "bazil.org/fuse/fs" + "golang.org/x/net/webdav" ) -var cmdMount = &cobra.Command{ - Use: "mount [flags] mountpoint", - Short: "mount the repository", +var cmdWeb = &cobra.Command{ + Use: "web [flags] [[hostname]:port]", + Short: "mount the repository as a WebDAV server", Long: ` -The "mount" command mounts the repository via fuse to a directory. This is a -read-only mount. +The "web" command mounts the repository as a WebDAV server. This is a +read-only interface. `, RunE: func(cmd *cobra.Command, args []string) error { - return runMount(mountOptions, globalOptions, args) + return runWeb(webOptions, globalOptions, args) }, } -// MountOptions collects all options for the mount command. -type MountOptions struct { - OwnerRoot bool -} +// WebOptions collects all options for the mount command. +type WebOptions struct{} -var mountOptions MountOptions +var webOptions WebOptions func init() { - cmdRoot.AddCommand(cmdMount) - - cmdMount.Flags().BoolVar(&mountOptions.OwnerRoot, "owner-root", false, "use 'root' as the owner of files and dirs") + cmdRoot.AddCommand(cmdWeb) } -func mount(opts MountOptions, gopts GlobalOptions, mountpoint string) error { - debug.Log("start mount") - defer debug.Log("finish mount") +func web(opts WebOptions, gopts GlobalOptions, address string) error { + debug.Log("start web") + defer debug.Log("finish web") repo, err := OpenRepository(gopts) if err != nil { @@ -57,57 +53,22 @@ func mount(opts MountOptions, gopts GlobalOptions, mountpoint string) error { return err } - if _, err := resticfs.Stat(mountpoint); os.IsNotExist(errors.Cause(err)) { - Verbosef("Mountpoint %s doesn't exist, creating it\n", mountpoint) - err = resticfs.Mkdir(mountpoint, os.ModeDir|0700) - if err != nil { - return err - } - } - c, err := systemFuse.Mount( - mountpoint, - systemFuse.ReadOnly(), - systemFuse.FSName("restic"), - ) - if err != nil { - return err + fsHandler := &webdav.Handler{ + FileSystem: resticWebdav.NewWebdavFS(fuse.NewSnapshotsDir(repo, true)), + LockSystem: webdav.NewMemLS(), + Logger: nil, } - Printf("Now serving the repository at %s\n", mountpoint) - Printf("Don't forget to umount after quitting!\n") - - root := fs.Tree{} - root.Add("snapshots", fuse.NewSnapshotsDir(repo, opts.OwnerRoot)) - - debug.Log("serving mount at %v", mountpoint) - err = fs.Serve(c, &root) - if err != nil { - return err - } - - <-c.Ready - return c.MountError + debug.Log("serving mount at %v", address) + return http.ListenAndServe(address, fsHandler) } -func umount(mountpoint string) error { - return systemFuse.Unmount(mountpoint) -} - -func runMount(opts MountOptions, gopts GlobalOptions, args []string) error { +func runWeb(opts WebOptions, gopts GlobalOptions, args []string) error { if len(args) == 0 { return errors.Fatalf("wrong number of parameters") } - mountpoint := args[0] + address := args[0] - AddCleanupHandler(func() error { - debug.Log("running umount cleanup handler for mount at %v", mountpoint) - err := umount(mountpoint) - if err != nil { - Warnf("unable to umount (maybe already umounted?): %v\n", err) - } - return nil - }) - - return mount(opts, gopts, mountpoint) + return web(opts, gopts, address) } diff --git a/src/restic/webdav/dir.go b/src/restic/webdav/dir.go new file mode 100644 index 000000000..27905db02 --- /dev/null +++ b/src/restic/webdav/dir.go @@ -0,0 +1,111 @@ +package webdav + +import ( + "os" + + "restic/debug" + + "bazil.org/fuse/fs" +) + +// This is the type for "directory-like" nodes we get from the FUSE layer. +type fsDirType interface { + fs.HandleReadDirAller + fs.NodeStringLookuper + fs.Node +} + +// Implements webdav.File for a restic snapshot directory +type dir struct { + // Name of the root snapshot directory + name string + // The root snapshots dir this file is coupled to. + dirNode fsDirType +} + +func NewDir(name string, dirNode fsDirType) *dir { + if dirNode == nil { + return nil + } + + return &dir{ + name: name, + dirNode: dirNode, + } +} + +// Read-only system never needs to clean up with restic. +func (this *dir) Close() error { + debug.Log(this.name) + return nil +} + +// TODO: allow directories to be rendered as an index. +func (this *dir) Read(p []byte) (n int, err error) { + debug.Log(this.name) + return 0, nil +} + +func (this *dir) Write(p []byte) (n int, err error) { + debug.Log(this.name) + return 0, os.ErrInvalid +} + +func (this *dir) Seek(offset int64, whence int) (int64, error) { + debug.Log(this.name) + return 0, os.ErrInvalid +} + +// Return the contents of the current directory, except for symlinks +// (there is no wide-spread support for handling symlinks in webdav). +func (this *dir) Readdir(count int) ([]os.FileInfo, error) { + debug.Log(this.name) + + fileInfos := []os.FileInfo{} + + dirContents, err := this.dirNode.ReadDirAll(ctx) + if err != nil { + return []os.FileInfo{}, err + } + + for _, dirEnt := range dirContents { + debug.Log(dirEnt.Name) + entNode, err := this.dirNode.Lookup(ctx, dirEnt.Name) + if err != nil { + debug.Log("error while looking up directory contents: %v %v", dirEnt, err) + continue + } + + if _, isLnk := entNode.(fsLinkType); isLnk { + debug.Log("skipping symlink: ", dirEnt) + continue + } + + fileInfo := FileInfo{ + Filename: dirEnt.Name, + } + + if err := entNode.Attr(ctx, &fileInfo.Attr); err != nil { + debug.Log("error retrieving attrs for %v : %v", dirEnt.Name, err) + continue + } + + fileInfos = append(fileInfos, os.FileInfo(&fileInfo)) + } + + return fileInfos, nil +} + +func (this *dir) Stat() (os.FileInfo, error) { + debug.Log(this.name) + fileInfo := FileInfo{ + Filename: this.name, + } + + if err := this.dirNode.Attr(ctx, &fileInfo.Attr); err != nil { + debug.Log("error retrieving attrs for %v : %v", this.name, err) + return nil, err + } + + return os.FileInfo(&fileInfo), nil +} diff --git a/src/restic/webdav/file.go b/src/restic/webdav/file.go new file mode 100644 index 000000000..f48de2bf0 --- /dev/null +++ b/src/restic/webdav/file.go @@ -0,0 +1,153 @@ + +package webdav + +import ( + "os" + + "restic/debug" + + "bazil.org/fuse/fs" + "bazil.org/fuse" + "io" +) + +// This is the type for "file-like" nodes we get from the FUSE layer. +type fsFileType interface { + fs.HandleReader + fs.HandleReleaser + fs.Node +} + +// Implements webdav.File for a restic backend +type file struct { + name string + fileNode fsFileType + + // Offset into the files content. + offset int64 +} + +func NewFile(name string, fileNode fsFileType) *file { + if fileNode == nil { + return nil + } + + return &file{ + name: name, + fileNode: fileNode, + offset: 0, + } +} + +// Read-only system never needs to clean up with restic. +func (this *file) Close() error { + debug.Log(this.name) + return this.fileNode.Release(ctx, nil) +} + +func (this *file) Read(p []byte) (n int, err error) { + debug.Log(this.name) + // Construct a read request to the underlying restic fuse.File + req := &fuse.ReadRequest{ + Dir: false, + Handle: 0, + Offset: this.offset, + Size: len(p), + Flags: 0, + LockOwner: 0, + FileFlags: 0, + } + + resp := &fuse.ReadResponse{ + Data: p, + } + + // Stat ourselves to figure out the size below. + fi, err := this.Stat() + if err != nil { + return 0, err + } + + // Do the read via the fuse wrapper + if err := this.fileNode.Read(ctx, req, resp); err != nil { + return 0, err + } + + // Update the file-offset by the number of read bytes. + this.offset += int64(len(resp.Data)) + + // FIXME: handle if somehow we read past the end of the file? + + // Check for EOF, and if so, return it. + if this.offset == fi.Size() { + err = io.EOF + } + + return len(resp.Data), err +} + +func (this *file) Write(p []byte) (n int, err error) { + debug.Log(this.name) + return 0, os.ErrInvalid +} + +func (this *file) Seek(offset int64, whence int) (int64, error) { + debug.Log(this.name) + // Stat ourselves to figure out the size. + fi, err := this.Stat() + if err != nil { + return this.offset, err + } + + // Can't be negative + if offset < 0 { + return this.offset, os.ErrInvalid + } + + switch whence { + case io.SeekStart: + this.offset = offset + // If seek past end, then seek to end and return EOF + if this.offset > fi.Size() { + this.offset = fi.Size() + return this.offset, io.EOF + } + return this.offset, nil + case io.SeekEnd: + // If seek past beginning, return an invalid operation error + if offset > fi.Size() { + this.offset = 0 + return this.offset, os.ErrInvalid + } + this.offset = fi.Size() - offset + return this.offset, nil + case io.SeekCurrent: + if this.offset + offset > fi.Size() { + this.offset = fi.Size() + return this.offset, io.EOF + } + return this.offset, nil + default: + return this.offset, os.ErrInvalid + } +} + +func (this *file) Readdir(count int) ([]os.FileInfo, error) { + debug.Log(this.name) + debug.Log("%v", count) + return []os.FileInfo{}, os.ErrInvalid +} + +func (this *file) Stat() (os.FileInfo, error) { + debug.Log(this.name) + fileInfo := FileInfo{ + Filename: this.name, + } + + if err := this.fileNode.Attr(ctx, &fileInfo.Attr); err != nil { + debug.Log("error retrieving attrs for %v : %v", this.name, err) + return nil, err + } + + return os.FileInfo(&fileInfo), nil +} \ No newline at end of file diff --git a/src/restic/webdav/fileinfo.go b/src/restic/webdav/fileinfo.go new file mode 100644 index 000000000..ba951cd1d --- /dev/null +++ b/src/restic/webdav/fileinfo.go @@ -0,0 +1,39 @@ +package webdav + +import ( + "os" + "time" + "bazil.org/fuse" +) + +// Implements os.FileInfo on top of fuse.Attr +type FileInfo struct { + // The file basename + Filename string + // Underlying fuse.Attr provider of the file + fuse.Attr +} + +func (this FileInfo) Name() string { + return this.Filename +} + +func (this FileInfo) Size() int64 { + return int64(this.Attr.Size) +} + +func (this FileInfo) Mode() os.FileMode { + return this.Attr.Mode +} + +func (this FileInfo) ModTime() time.Time { + return this.Attr.Mtime +} + +func (this FileInfo) IsDir() bool { + return (this.Attr.Mode & os.ModeDir) != 0 +} + +func (this FileInfo) Sys() interface{} { + return &this.Attr +} \ No newline at end of file diff --git a/src/restic/webdav/link.go b/src/restic/webdav/link.go new file mode 100644 index 000000000..159ad4139 --- /dev/null +++ b/src/restic/webdav/link.go @@ -0,0 +1,85 @@ + +package webdav + +import ( + "os" + + "restic/debug" + + "bazil.org/fuse/fs" + + "golang.org/x/net/webdav" +) + +// This is the type for "symlink" nodes we get from the FUSE layer. This is +// necessary because these nodes need to support file-like operations but the +// fuse-layer abstraction only returns a link interface. +type fsLinkType interface { + fs.NodeReadlinker + fs.Node +} + +// Implements webdav.File for a restic backend +type link struct { + name string + linkNode fsLinkType + + target webdav.File +} + +func NewLink(name string, linkNode fsLinkType, target webdav.File) *link { + if linkNode == nil { + return nil + } + + return &link{ + name: name, + linkNode: linkNode, + target: target, + } +} + +// We don't hold a persistent reference when we're created, so does that mean +// we should do so and clean up the target node here? +func (this *link) Close() error { + debug.Log(this.name) + return nil +} + +func (this *link) Read(p []byte) (n int, err error) { + debug.Log(this.name) + + return this.target.Read(p) +} + +func (this *link) Write(p []byte) (n int, err error) { + debug.Log(this.name) + + return this.target.Write(p) +} + +func (this *link) Seek(offset int64, whence int) (int64, error) { + debug.Log(this.name) + + return this.target.Seek(offset, whence) +} + +func (this *link) Readdir(count int) ([]os.FileInfo, error) { + debug.Log(this.name) + + return this.target.Readdir(count) +} + +func (this *link) Stat() (os.FileInfo, error) { + debug.Log(this.name) + fileInfo := FileInfo{ + Filename: this.name, + } + + if err := this.linkNode.Attr(ctx, &fileInfo.Attr); err != nil { + debug.Log("error retrieving attrs for %v : %v", this.name, err) + return nil, err + } + + return os.FileInfo(&fileInfo), nil +} \ No newline at end of file diff --git a/src/restic/webdav/webdav.go b/src/restic/webdav/webdav.go index 0541cc2d8..ce3eb1d1b 100644 --- a/src/restic/webdav/webdav.go +++ b/src/restic/webdav/webdav.go @@ -1 +1,189 @@ +// +build !openbsd +// +build !windows + package webdav + +import ( + "os" + "strings" + + "restic/errors" + resticfuse "restic/fuse" + + "golang.org/x/net/webdav" + "golang.org/x/net/context" + + "restic/debug" + "bazil.org/fuse/fs" + "bazil.org/fuse" + "path" +) + +var ctx context.Context = context.Background() + +// Implements webdav.FileSystem interface. This is a wrapper to the +// fuse.Filesystem which handles the discrepancies. +type WebdavFS struct { + root *resticfuse.SnapshotsDir +} + +// Create a new webdav.FileSystem for the given repository +func NewWebdavFS(snapshotDir *resticfuse.SnapshotsDir) webdav.FileSystem { + if snapshotDir == nil { + return nil + } + + return &WebdavFS{ + root: snapshotDir, + } +} + +func (this *WebdavFS) Mkdir(name string, perm os.FileMode) error { + debug.Log(name) + return errors.New("WebdavFS is read only. MkDir is not supported.") +} + +func (this *WebdavFS) OpenFile(name string, flag int, perm os.FileMode) (webdav.File, error) { + filename, resolvedNode, err := this.resolve(name) + if err != nil { + debug.Log("resolved failed") + return nil, err + } + + // Determine what type of node we landed on. + switch node := resolvedNode.(type) { + // Is is a directory type? + case fsDirType: + debug.Log("dir: %v", name) + return NewDir(filename, node), nil + case fsFileType: + debug.Log("file: %v", name) + return NewFile(filename, node), nil + case fsLinkType: + debug.Log("link: %v", name) + // We need to return not found *here* if we try to open a broken symlink. + // Of course, nothing above us can handle this anyway, but its the right + // thing to do. + target, err := this.resolveLinkNode(filename, node) + if err != nil { + return nil, err + } + return NewLink(filename, node, target), nil + default: + debug.Log("UNKNOWN: %v", name) + return nil, os.ErrInvalid + } +} + +func (this *WebdavFS) RemoveAll(name string) error { + debug.Log("RemoveAll(%v)", name) + return errors.New("Unimplemented") +} + +func (this *WebdavFS) Rename(oldName, newName string) error { + debug.Log("Rename(%v,%v)", oldName, newName) + return errors.New("Unimplemented") +} + +func (this *WebdavFS) Stat(name string) (os.FileInfo, error) { + debug.Log(name) + + filename, resolvedNode, err := this.resolve(name) + if err != nil { + return nil, err + } + + // Return the file info as a fuse -> os.FileInfo mapper + fileInfo := FileInfo{ + Filename: filename, + } + + if err := resolvedNode.Attr(ctx, &fileInfo.Attr); err != nil { + return nil, os.ErrInvalid + } + + return fileInfo, nil +} + +func (this *WebdavFS) resolve(name string) (string, fs.Node, error) { + // Clean the path + name = path.Clean(name) + + // Remove any leading slash, since it makes path sense logic weird + name = strings.TrimPrefix(name, "/") + + // Split the path by slashes, remove 0-length tail + pathlist := strings.Split(name, "/") + if len(pathlist) == 1 && pathlist[0] == "" { + pathlist = []string{} + } + + // The first node is always the snapshot root, so set it implicitly. + var filename string + var currentNode fs.Node + + filename = "/" + currentNode = this.root + + // Recursively resolve each path component till we arrive at the file. + // Skip the first node since it's the snapshot directory + var err error + for _, pathElem := range pathlist { + debug.Log("resolving: %v", pathElem) + // Set the current path element filename + filename = pathElem + + // Check that we are still able to lookup nodes + oldNode, ok := currentNode.(fs.NodeStringLookuper) + if !ok { + return filename, nil, os.ErrNotExist + } + + if currentNode, err = oldNode.Lookup(ctx, pathElem) ; err != nil { + return filename, nil, err + } + } + + return filename, currentNode, err +} + +// Helper function to safely resolve link nodes. +func (this *WebdavFS) resolveLinkNode(name string, node fsLinkType) (webdav.File, error) { + // Keep track of inodes we've seen before. If they start to recur, then + // we're in a look and should fail. + //inodes := make(map[uint64]interface{}) + + // Loop resolution until finished. + resolvedNode := fs.Node(node) + realname := name + for { + // TODO: do we need multi-level resolution (probably) + switch v := resolvedNode.(type) { + case fsDirType: + return NewDir(realname, v), nil + case fsFileType: + return NewFile(realname, v), nil + case fsLinkType: + // Still a symlink - continue resolving. + req := &fuse.ReadlinkRequest{} + nextname, err := v.Readlink(ctx, req) ; + if err != nil { + return nil, os.ErrNotExist + } + + debug.Log("resolved: %s => %s", realname, nextname) + realname = nextname + // TODO: rewrite symlinks to be snapshot-relative + + // Lookup the next node... + targetNode, err := this.root.Lookup(ctx, realname) + if err != nil { + return nil, os.ErrNotExist + } + resolvedNode = targetNode + + default: + return nil, os.ErrInvalid + } + } +} \ No newline at end of file