diff --git a/fuse/api.go b/fuse/api.go index 7db5f0d0..fbdebfab 100644 --- a/fuse/api.go +++ b/fuse/api.go @@ -88,6 +88,45 @@ // filesystems in terms of path names. Working with path names is somewhat // easier compared to inodes, however renames can be racy. Do not use pathfs if // you care about correctness. +// +// # Mount styles +// +// The NewServer() handles mounting the filesystem, which +// involves opening `/dev/fuse` and calling the +// `mount(2)` syscall. The latter needs root permissions. +// This is handled in one of three ways: +// +// 1) go-fuse opens `/dev/fuse` and executes the `fusermount` +// setuid-root helper to call `mount(2)` for us. This is the default. +// Does not need root permissions but needs `fusermount` installed. +// +// 2) If `MountOptions.DirectMount` is set, go-fuse calls `mount(2)` itself. +// Needs root permissions, but works without `fusermount`. +// +// 3) If `mountPoint` has the magic `/dev/fd/N` syntax, it means that that a +// privileged parent process: +// +// * Opened /dev/fuse +// +// * Called mount(2) on a real mountpoint directory that we don't know about +// +// * Inherited the fd to /dev/fuse to us +// +// * Informs us about the fd number via /dev/fd/N +// +// This magic syntax originates from libfuse [1] and allows the FUSE server to +// run without any privileges and without needing `fusermount`, as the parent +// process performs all privileged operations. +// +// The "privileged parent" is usually a container manager like Singularity [2], +// but for testing, it can also be the `mount.fuse3` helper with the +// `drop_privileges,setuid=$USER` flags. Example below for gocryptfs: +// +// $ sudo mount.fuse3 "/usr/local/bin/gocryptfs#/tmp/cipher" /tmp/mnt -o drop_privileges,setuid=$USER +// +// [1] https://github.com/libfuse/libfuse/commit/64e11073b9347fcf9c6d1eea143763ba9e946f70 +// +// [2] https://sylabs.io/guides/3.7/user-guide/bind_paths_and_mounts.html#fuse-mounts package fuse // Types for users to implement. diff --git a/fuse/mount_linux.go b/fuse/mount_linux.go index a8a37eb4..eab3d99d 100644 --- a/fuse/mount_linux.go +++ b/fuse/mount_linux.go @@ -12,6 +12,7 @@ import ( "os/exec" "path" "path/filepath" + "strconv" "strings" "syscall" "unsafe" @@ -116,18 +117,12 @@ func mountDirect(mountPoint string, opts *MountOptions, ready chan<- error) (fd return } -// Create a FUSE FS on the specified mount point. The returned -// mount point is always absolute. -func mount(mountPoint string, opts *MountOptions, ready chan<- error) (fd int, err error) { - if opts.DirectMount { - fd, err := mountDirect(mountPoint, opts, ready) - if err == nil { - return fd, nil - } else if opts.Debug { - log.Printf("mount: failed to do direct mount: %s", err) - } - } - +// callFusermount calls the `fusermount` suid helper with the right options so +// that it: +// * opens `/dev/fuse` +// * mount()s this file descriptor to `mountPoint` +// * passes this file descriptor back to use via a unix domain socket +func callFusermount(mountPoint string, opts *MountOptions) (fd int, err error) { local, remote, err := unixgramSocketpair() if err != nil { return @@ -169,11 +164,53 @@ func mount(mountPoint string, opts *MountOptions, ready chan<- error) (fd int, e return -1, err } + return +} + +// parseFuseFd checks if `mountPoint` is the special form /dev/fd/N (with N >= 0), +// and returns N in this case. Returns -1 otherwise. +func parseFuseFd(mountPoint string) (fd int) { + dir, file := path.Split(mountPoint) + if dir != "/dev/fd/" { + return -1 + } + fd, err := strconv.Atoi(file) + if err != nil || fd <= 0 { + return -1 + } + return fd +} + +// Create a FUSE FS on the specified mount point. The returned +// mount point is always absolute. +func mount(mountPoint string, opts *MountOptions, ready chan<- error) (fd int, err error) { + if opts.DirectMount { + fd, err := mountDirect(mountPoint, opts, ready) + if err == nil { + return fd, nil + } else if opts.Debug { + log.Printf("mount: failed to do direct mount: %s", err) + } + } + + // Magic `/dev/fd/N` mountpoint. See the docs for NewServer() for how this + // works. + fd = parseFuseFd(mountPoint) + if fd >= 0 { + if opts.Debug { + log.Printf("mount: magic mountpoint %q, using fd %d", mountPoint, fd) + } + } else { + // Usual case: mount via the `fusermount` suid helper + fd, err = callFusermount(mountPoint, opts) + if err != nil { + return + } + } // golang sets CLOEXEC on file descriptors when they are // acquired through normal operations (e.g. open). // Buf for fd, we have to set CLOEXEC manually syscall.CloseOnExec(fd) - close(ready) return fd, err } diff --git a/fuse/mount_linux_test.go b/fuse/mount_linux_test.go new file mode 100644 index 00000000..06ff08a6 --- /dev/null +++ b/fuse/mount_linux_test.go @@ -0,0 +1,66 @@ +package fuse + +import ( + "fmt" + "io/ioutil" + "syscall" + "testing" +) + +// TestMountDevFd tests the special `/dev/fd/N` mountpoint syntax, where a +// privileged parent process opens /dev/fuse and calls mount() for us. +// +// In this test, we simulate a privileged parent by using the `fusermount` suid +// helper. +func TestMountDevFd(t *testing.T) { + realMountPoint, err := ioutil.TempDir("", t.Name()) + if err != nil { + t.Fatal(err) + } + defer syscall.Rmdir(realMountPoint) + + // Call the fusermount suid helper to obtain the file descriptor in place + // of a privileged parent. + var fuOpts MountOptions + fd, err := callFusermount(realMountPoint, &fuOpts) + if err != nil { + t.Fatal(err) + } + fdMountPoint := fmt.Sprintf("/dev/fd/%d", fd) + + // Real test starts here: + // See if we can feed fdMountPoint to NewServer + fs := NewDefaultRawFileSystem() + opts := MountOptions{ + Debug: true, + } + srv, err := NewServer(fs, fdMountPoint, &opts) + if err != nil { + t.Fatal(err) + } + + go srv.Serve() + if err := srv.WaitMount(); err != nil { + t.Fatal(err) + } + + // If we are actually mounted, we should get ENOSYS. + // + // This won't deadlock despite pollHack not working for `/dev/fd/N` mounts + // because functions in the syscall package don't use the poller. + var st syscall.Stat_t + err = syscall.Stat(realMountPoint, &st) + if err != syscall.ENOSYS { + t.Errorf("expected ENOSYS, got %v", err) + } + + // Cleanup is somewhat tricky because `srv` does not know about + // `realMountPoint`, so `srv.Unmount()` cannot work. + // + // A normal user has to call `fusermount -u` for themselves to unmount. + // But in this test we can monkey-patch `srv.mountPoint`. + srv.mountPoint = realMountPoint + if err := srv.Unmount(); err != nil { + t.Error(err) + } +} diff --git a/fuse/server.go b/fuse/server.go index 98fd4d0c..8003c395 100644 --- a/fuse/server.go +++ b/fuse/server.go @@ -118,10 +118,20 @@ func (ms *Server) RecordLatencies(l LatencyMap) { // Unmount calls fusermount -u on the mount. This has the effect of // shutting down the filesystem. After the Server is unmounted, it // should be discarded. +// +// Does not work when we were mounted with the magic /dev/fd/N mountpoint syntax, +// as we do not know the real mountpoint. Unmount using +// +// fusermount -u /path/to/real/mountpoint +// +/// in this case. func (ms *Server) Unmount() (err error) { if ms.mountPoint == "" { return nil } + if parseFuseFd(ms.mountPoint) >= 0 { + return fmt.Errorf("Cannot unmount magic mountpoint %q. Please use `fusermount -u REALMOUNTPOINT` instead.", ms.mountPoint) + } delay := time.Duration(0) for try := 0; try < 5; try++ { err = unmount(ms.mountPoint, ms.opts) @@ -144,7 +154,11 @@ func (ms *Server) Unmount() (err error) { return err } -// NewServer creates a server and attaches it to the given directory. +// NewServer creates a FUSE server and attaches ("mounts") it to the +// `mountPoint` directory. +// +// See the "Mount styles" section in the package documentation if you want to +// know about the inner workings of the mount process. Usually you do not. func NewServer(fs RawFileSystem, mountPoint string, opts *MountOptions) (*Server, error) { if opts == nil { opts = &MountOptions{ @@ -1117,5 +1131,10 @@ func (ms *Server) WaitMount() error { if err != nil { return err } + if parseFuseFd(ms.mountPoint) >= 0 { + // Magic `/dev/fd/N` mountpoint. We don't know the real mountpoint, so + // we cannot run the poll hack. + return nil + } return pollHack(ms.mountPoint) }