Skip to content

Commit

Permalink
fuse: enable id-mapped mount on supported kernels
Browse files Browse the repository at this point in the history
The id-mapped mount feature for FUSE was introduced in Linux v6.12.
This patch added a config option to enable the feature on supported kernels.

Using id-mapped mount for FUSE requires the filesystem mounted with the "default_permissions" parameter and must trust the kernel to perform UID/GID-based checks correctly.

For id-mapped mounts, FUSE will send mapped UID/GID in requests
that create new inodes, and -1 for the others.

More information in https://lwn.net/Articles/985803/ .

A unit test was also written to validate the feature. The test
requires root privilege to run due to the open_tree syscall.

Change-Id: I6a93a0cd2109a03c5bb54946fba00c535d9d3d21
  • Loading branch information
henry118 authored and hanwen committed Jan 16, 2025
1 parent a1ce67d commit aa9c516
Show file tree
Hide file tree
Showing 8 changed files with 156 additions and 3 deletions.
4 changes: 2 additions & 2 deletions all.bash
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ GOOS=freebsd go build ./fs/... ./example/loopback/...
GO_TEST="go test -timeout 5m -p 1 -count 1"
# Run all tests as current user
$GO_TEST ./...
# Direct-mount tests need to run as root
sudo env PATH=$PATH $GO_TEST -run 'Test(DirectMount|Passthrough)' ./fs ./fuse
# The following tests need to run as root
sudo env PATH=$PATH $GO_TEST -run 'Test(DirectMount|Passthrough|IDMappedMount)' ./fs ./fuse

make -C benchmark
go test ./benchmark -test.bench '.*' -test.cpu 1,2
2 changes: 2 additions & 0 deletions example/loopback/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func main() {
// Scans the arg list and sets up flags
debug := flag.Bool("debug", false, "print debugging messages.")
other := flag.Bool("allow-other", false, "mount with -o allowother.")
idmap := flag.Bool("idmapped", false, "enable id-mapped mount")
quiet := flag.Bool("q", false, "quiet")
ro := flag.Bool("ro", false, "mount read-only")
directmount := flag.Bool("directmount", false, "try to call the mount syscall instead of executing fusermount")
Expand Down Expand Up @@ -105,6 +106,7 @@ func main() {
Debug: *debug,
DirectMount: *directmount,
DirectMountStrict: *directmountstrict,
IDMappedMount: *idmap,
FsName: orig, // First column in "df -T": original dir
Name: "loopback", // Second column in "df -T" will be shown as "fuse." + Name
},
Expand Down
115 changes: 115 additions & 0 deletions fs/idmapped_mount_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright 2025 the Go-FUSE Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package fs

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"

"github.com/hanwen/go-fuse/v2/fuse"
"golang.org/x/sys/unix"
)

func TestIDMappedMount(t *testing.T) {
// sys_open_tree requires CAP_SYS_ADMIN
if os.Geteuid() != 0 {
t.Skip("id-mapped mount requires CAP_SYS_ADMIN")
}

tc := newTestCase(t, &testOptions{idMappedMount: true})
tc.writeOrig("file", "hello", 0644)

fi, err := os.Lstat(filepath.Join(tc.origDir, "file"))
if err != nil {
t.Fatalf("stat for path %s failed: %v", filepath.Join(tc.origDir, "file"), err)
}
st := fuse.ToStatT(fi)

if tc.server.KernelSettings().Flags64()&fuse.CAP_ALLOW_IDMAP == 0 {
t.Skip("Kernel does not support id-mapped mount")
}

const offset = 10000
fd, err := usernsFD(offset)
if err != nil {
t.Fatalf("failed to get user namespace FD: %v", err)
}
defer fd.Close()

idDir := t.TempDir()
if err = idMapMount(tc.mntDir, idDir, int(fd.Fd())); err != nil {
t.Fatalf("id-mapped mount failed: %v", err)
}
defer unix.Unmount(idDir, 0)

mfi, err := os.Lstat(filepath.Join(idDir, "file"))
if err != nil {
t.Fatalf("stat for path %s failed: %v", filepath.Join(idDir, "file"), err)
}
mst := fuse.ToStatT(mfi)

if st.Uid+offset != mst.Uid {
t.Errorf("Uid %v + offset %v != mapped Uid %v", st.Uid, offset, mst.Uid)
}
if st.Gid+offset != mst.Gid {
t.Errorf("Gid %v + offset %v != mapped Gid %v", st.Gid, offset, mst.Gid)
}
}

func idMapMount(source, target string, fd int) (err error) {
const ignored = 0
dFd, err := unix.OpenTree(ignored, source, uint(unix.OPEN_TREE_CLONE|unix.OPEN_TREE_CLOEXEC|unix.AT_EMPTY_PATH))
if err != nil {
return fmt.Errorf("open tree failed %s: %w", source, err)
}
defer unix.Close(dFd)
if err = unix.MountSetattr(dFd, "", unix.AT_EMPTY_PATH, &unix.MountAttr{Attr_set: unix.MOUNT_ATTR_IDMAP, Userns_fd: uint64(fd)}); err != nil {
return fmt.Errorf("set attr for %s failed: %w", source, err)
}
if err = unix.MoveMount(dFd, "", ignored, target, unix.MOVE_MOUNT_F_EMPTY_PATH); err != nil {
return fmt.Errorf("move mount to %s failed: %w", target, err)
}
return nil
}

func usernsFD(offset int) (*os.File, error) {
var err error
args := []string{"sleep", "1h"}
if args[0], err = exec.LookPath("sleep"); err != nil {
return nil, fmt.Errorf("failed to find sleep binary: %w", err)
}
p, err := os.StartProcess(args[0], args, &os.ProcAttr{
Sys: &syscall.SysProcAttr{
Cloneflags: unix.CLONE_NEWUSER,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: offset,
Size: offset,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: offset,
Size: offset,
},
},
Pdeathsig: syscall.SIGKILL,
},
})
if err != nil {
return nil, fmt.Errorf("failed to start process: %w", err)
}
defer func() {
p.Kill()
p.Wait()
}()
return os.Open(fmt.Sprintf("/proc/%d/ns/user", p.Pid))
}
5 changes: 5 additions & 0 deletions fs/simple_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type testOptions struct {
directMount bool // sets MountOptions.DirectMount
directMountStrict bool // sets MountOptions.DirectMountStrict
disableSplice bool // sets MountOptions.DisableSplice
idMappedMount bool // sets MountOptions.IDMappedMount
}

// newTestCase creates the directories `orig` and `mnt` inside a temporary
Expand Down Expand Up @@ -114,13 +115,17 @@ func newTestCase(t *testing.T, opts *testOptions) *testCase {
DirectMountStrict: opts.directMountStrict,
EnableLocks: opts.enableLocks,
DisableSplice: opts.disableSplice,
IDMappedMount: opts.idMappedMount,
}
if !opts.suppressDebug {
mOpts.Debug = testutil.VerboseTest()
}
if opts.ro {
mOpts.Options = append(mOpts.Options, "ro")
}
if opts.idMappedMount {
mOpts.Options = append(mOpts.Options, "default_permissions")
}
tc.server, err = fuse.NewServer(tc.rawFS, tc.mntDir, mOpts)
if err != nil {
t.Fatal(err)
Expand Down
12 changes: 12 additions & 0 deletions fuse/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,18 @@ type MountOptions struct {

// Maximum stacking depth for passthrough files. Defaults to 1.
MaxStackDepth int

// Enable ID-mapped mount if the Kernel supports it.
// ID-mapped mount allows the device to be mounted on the system
// with the IDs remapped (via mount_setattr, move_mount syscalls) to
// those of the user on the local system.
//
// Enabling this flag automatically sets the "default_permissions"
// mount option. This is required by FUSE to delegate the UID/GID-based
// permission checks to the kernel. For requests that create new inodes,
// FUSE will send the mapped UID/GIDs. For all other requests, FUSE
// will send "-1".
IDMappedMount bool
}

// RawFileSystem is an interface close to the FUSE wire protocol.
Expand Down
3 changes: 3 additions & 0 deletions fuse/mount_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ func mountDirect(mountPoint string, opts *MountOptions, ready chan<- error) (fd
if opts.AllowOther {
r = append(r, "allow_other")
}
if opts.IDMappedMount && !opts.containsOption("default_permissions") {
r = append(r, "default_permissions")
}

if opts.Debug {
opts.Logger.Printf("mountDirect: calling syscall.Mount(%q, %q, %q, %#x, %q)",
Expand Down
6 changes: 5 additions & 1 deletion fuse/opcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func doInit(server *protocolServer, req *request) {
kernelFlags := input.Flags64()
server.kernelSettings = *input
kernelFlags &= (CAP_ASYNC_READ | CAP_BIG_WRITES | CAP_FILE_OPS |
CAP_READDIRPLUS | CAP_NO_OPEN_SUPPORT | CAP_PARALLEL_DIROPS | CAP_MAX_PAGES | CAP_RENAME_SWAP | CAP_PASSTHROUGH)
CAP_READDIRPLUS | CAP_NO_OPEN_SUPPORT | CAP_PARALLEL_DIROPS | CAP_MAX_PAGES | CAP_RENAME_SWAP | CAP_PASSTHROUGH | CAP_ALLOW_IDMAP)

if server.opts.EnableLocks {
kernelFlags |= CAP_FLOCK_LOCKS | CAP_POSIX_LOCKS
Expand All @@ -119,6 +119,10 @@ func doInit(server *protocolServer, req *request) {
// Clear CAP_READDIRPLUS
kernelFlags &= ^uint64(CAP_READDIRPLUS)
}
if !server.opts.IDMappedMount {
// Clear CAP_ALLOW_IDMAP
kernelFlags &= ^uint64(CAP_ALLOW_IDMAP)
}

dataCacheMode := kernelFlags & CAP_AUTO_INVAL_DATA
if server.opts.ExplicitDataCacheControl {
Expand Down
12 changes: 12 additions & 0 deletions fuse/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,9 @@ func (o *MountOptions) optionsStrings() []string {
if runtime.GOOS == "darwin" {
r = append(r, "daemon_timeout=0")
}
if o.IDMappedMount && !o.containsOption("default_permissions") {
r = append(r, "default_permissions")
}

// Commas and backslashs in an option need to be escaped, because
// options are separated by a comma and backslashs are used to
Expand All @@ -293,6 +296,15 @@ func (o *MountOptions) optionsStrings() []string {
return rEscaped
}

func (o *MountOptions) containsOption(opt string) bool {
for _, o := range o.Options {
if o == opt {
return true
}
}
return false
}

// DebugData returns internal status information for debugging
// purposes.
func (ms *Server) DebugData() string {
Expand Down

0 comments on commit aa9c516

Please sign in to comment.