From 6ffb07f87384208c1416c07d514194fef279c1b8 Mon Sep 17 00:00:00 2001 From: Jakob Unterwurzacher Date: Thu, 12 Aug 2021 12:07:40 +0200 Subject: [PATCH 1/2] fuse: move fusermount logic to new callFusermount() helper This makes the function available to testing, which will be used in the next commit. Change-Id: I2df7aaf50748f209964da6f14764e81237d49027 --- fuse/mount_linux.go | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/fuse/mount_linux.go b/fuse/mount_linux.go index a8a37eb4..e1c0e53f 100644 --- a/fuse/mount_linux.go +++ b/fuse/mount_linux.go @@ -116,18 +116,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,6 +163,27 @@ func mount(mountPoint string, opts *MountOptions, ready chan<- error) (fd int, e return -1, err } + 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) + } + } + + // 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 From 09dcd030982dea4e423859eb2af2d1137f8f8dd4 Mon Sep 17 00:00:00 2001 From: Jakob Unterwurzacher Date: Thu, 12 Aug 2021 12:12:47 +0200 Subject: [PATCH 2/2] fuse: support special /dev/fd/N mountpoint libfuse introduced [1] a special `/dev/fd/N` syntax for the mountpoint: It means that a privileged parent process: * Opened /dev/fuse * Called mount() on a real mountpoint directory * Inherited the fd to /dev/fuse to us * Informs us about the fd number via /dev/fd/N This functionality is used to allow FUSE mounts inside containers that have neither root permissions nor suid binaries [2], and for the --drop_privileges flag of mount.fuse3 [4] Tested with singularity and gocryptfs and actually works [3]. v2: Added doccomment for NewServer. v3: Added specific error message on Server.Unmount(). v4: Moved mount details to package comment [1] https://github.com/libfuse/libfuse/commit/64e11073b9347fcf9c6d1eea143763ba9e946f70 [2] https://github.com/rfjakob/gocryptfs/issues/590 [3] $ singularity run --fusemount "host:gocryptfs --extpass echo --extpass test /tmp/a /mnt" docker://ubuntu INFO: Using cached SIF image Reading password from extpass program "echo", arguments: ["test"] Decrypting master key bash: /home/jakob/.cargo/env: No such file or directory bash: /home/jakob/.cargo/env: No such file or directory bash: /home/jakob/.cargo/env: No such file or directory Singularity> Filesystem mounted and ready. [4] man mount.fuse3 Change-Id: Ibcc2464b0ef1e3d236207981b487fd9a7d94c910 --- fuse/api.go | 39 ++++++++++++++++++++++++ fuse/mount_linux.go | 34 +++++++++++++++++---- fuse/mount_linux_test.go | 66 ++++++++++++++++++++++++++++++++++++++++ fuse/server.go | 21 ++++++++++++- 4 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 fuse/mount_linux_test.go 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 e1c0e53f..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" @@ -166,6 +167,20 @@ func callFusermount(mountPoint string, opts *MountOptions) (fd int, err error) { 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) { @@ -178,17 +193,24 @@ func mount(mountPoint string, opts *MountOptions, ready chan<- error) (fd int, e } } - // Usual case: mount via the `fusermount` suid helper - fd, err = callFusermount(mountPoint, opts) - if err != nil { - return + // 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) }