Skip to content

Commit

Permalink
fuse: support special /dev/fd/N mountpoint
Browse files Browse the repository at this point in the history
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] libfuse/libfuse@64e1107
[2] rfjakob/gocryptfs#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
  • Loading branch information
rfjakob authored and jiefenghuang committed Aug 6, 2024
1 parent 6ffb07f commit 09dcd03
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 7 deletions.
39 changes: 39 additions & 0 deletions fuse/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 28 additions & 6 deletions fuse/mount_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"os/exec"
"path"
"path/filepath"
"strconv"
"strings"
"syscall"
"unsafe"
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}
Expand Down
66 changes: 66 additions & 0 deletions fuse/mount_linux_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 20 additions & 1 deletion fuse/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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{
Expand Down Expand Up @@ -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)
}

0 comments on commit 09dcd03

Please sign in to comment.