Files
rclone/cmd/serve/nfs/cache.go
Nick Craig-Wood 04d1e2563a serve nfs: allow NFS clients to mount subpaths of the served remote
Previously the Mount RPC ignored the path component of the mount
request, so `server:/sub/dir` and `server:/` both landed at the root
of the served remote. The Mount handler now cleans the requested path
with path.Clean, looks it up in the VFS and serves a billy.Filesystem
rooted at that directory, refusing the mount if the path does not
exist or is not a plain directory.

A pathRewriter cache wraps the inner handle cache so that the same
file always produces the same NFS file handle regardless of which
mount minted it (and stable across server restarts for the disk and
symlink caches). This matches the traditional NFS expectation that a
subpath mount behaves like `cd` into a subtree.

nfsmount gains a --nfs-mount-path flag (default /) so clients can
select a subpath at mount time. This replaces a latent misuse of
--volname as the NFS mount path that was previously masked by the
server ignoring it.

Fixes #9442
2026-05-24 18:09:03 +01:00

324 lines
10 KiB
Go

//go:build unix
package nfs
import (
"bytes"
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"math"
"os"
"path"
"path/filepath"
"runtime"
"strings"
"sync"
billy "github.com/go-git/go-billy/v5"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/lib/encoder"
"github.com/rclone/rclone/lib/file"
"github.com/willscott/go-nfs"
nfshelper "github.com/willscott/go-nfs/helpers"
)
// Errors on cache initialisation
var (
ErrorSymlinkCacheNotSupported = errors.New("symlink cache not supported on " + runtime.GOOS)
ErrorSymlinkCacheNoPermission = errors.New("symlink cache must be run as root or with CAP_DAC_READ_SEARCH")
)
// Metadata files have the file handle of their source file with this
// suffixed so we can look them up directly from the file handle.
//
// Note that this is 4 bytes - using a non multiple of 4 will cause
// the Linux NFS client not to be able to read any files.
//
// The value is big endian 0x00000001
var metadataSuffix = []byte{0x00, 0x00, 0x00, 0x01}
// Cache controls the file handle cache implementation
type Cache interface {
// ToHandle takes a file and represents it with an opaque handle to reference it.
// In stateless nfs (when it's serving a unix fs) this can be the device + inode
// but we can generalize with a stateful local cache of handed out IDs.
ToHandle(f billy.Filesystem, path []string) []byte
// FromHandle converts from an opaque handle to the file it represents
FromHandle(fh []byte) (billy.Filesystem, []string, error)
// Invalidate the handle passed - used on rename and delete
InvalidateHandle(fs billy.Filesystem, handle []byte) error
// HandleLimit exports how many file handles can be safely stored by this cache.
HandleLimit() int
}
// Set the cache of the handler to the type required by the user
func (h *Handler) getCache() (c Cache, err error) {
fs.Debugf("nfs", "Starting %v handle cache", h.opt.HandleCache)
var inner Cache
switch h.opt.HandleCache {
case cacheMemory:
inner = nfshelper.NewCachingHandler(h, h.opt.HandleLimit)
case cacheDisk:
inner, err = newDiskHandler(h)
case cacheSymlink:
var dh *diskHandler
dh, err = newDiskHandler(h)
if err == nil {
err = dh.makeSymlinkCache()
}
inner = dh
default:
return nil, errors.New("unknown handle cache type")
}
if err != nil {
return nil, err
}
return &pathRewriter{inner: inner, rootFS: h.billyFS}, nil
}
// pathRewriter wraps a Cache so that all handles refer to absolute VFS
// paths via the root FS, regardless of which subpath mount the request
// came in through. This guarantees the same file always gets the same
// handle, matching the traditional NFS expectation that a subpath mount
// is just a "cd" into a subtree.
type pathRewriter struct {
inner Cache
rootFS *FS
}
// translate rewrites (f, splitPath) so calls into the inner cache always
// see (rootFS, absoluteSplitPath). For the root FS the inputs are
// returned unchanged, so root-only deployments keep the existing handle
// format (and on-disk hashes) byte-for-byte.
func (c *pathRewriter) translate(f billy.Filesystem, splitPath []string) (billy.Filesystem, []string) {
rfs, ok := f.(*FS)
if !ok || rfs.root == "" {
return f, splitPath
}
trimmed := strings.Trim(rfs.root, "/")
if trimmed == "" {
return c.rootFS, splitPath
}
prefix := strings.Split(trimmed, "/")
full := make([]string, 0, len(prefix)+len(splitPath))
full = append(full, prefix...)
full = append(full, splitPath...)
return c.rootFS, full
}
// ToHandle takes a file and represents it with an opaque handle to reference it.
func (c *pathRewriter) ToHandle(f billy.Filesystem, splitPath []string) []byte {
f, splitPath = c.translate(f, splitPath)
return c.inner.ToHandle(f, splitPath)
}
// FromHandle converts from an opaque handle to the file it represents
func (c *pathRewriter) FromHandle(fh []byte) (billy.Filesystem, []string, error) {
return c.inner.FromHandle(fh)
}
// InvalidateHandle invalidates the handle passed - used on rename and delete
func (c *pathRewriter) InvalidateHandle(f billy.Filesystem, handle []byte) error {
f, _ = c.translate(f, nil)
return c.inner.InvalidateHandle(f, handle)
}
// HandleLimit exports how many file handles can be safely stored by this cache.
func (c *pathRewriter) HandleLimit() int {
return c.inner.HandleLimit()
}
// diskHandler implements an on disk NFS file handle cache
type diskHandler struct {
mu sync.RWMutex
cacheDir string
billyFS billy.Filesystem
write func(fh []byte, cachePath string, fullPath string) ([]byte, error)
read func(fh []byte, cachePath string) ([]byte, error)
remove func(fh []byte, cachePath string) error
suffix func(fh []byte) []byte // returns nil for no suffix or the suffix
handleType int32 //nolint:unused // used by the symlink cache
metadata string // extension for metadata
}
// Create a new disk handler
func newDiskHandler(h *Handler) (dh *diskHandler, err error) {
cacheDir := h.opt.HandleCacheDir
// If cacheDir isn't set then make one from the config
if cacheDir == "" {
// How the VFS was configured
configString := fs.ConfigString(h.vfs.Fs())
// Turn it into a valid OS directory name
dirName := encoder.OS.ToStandardName(configString)
cacheDir = filepath.Join(config.GetCacheDir(), "serve-nfs-handle-cache-"+h.opt.HandleCache.String(), dirName)
}
// Create the cache dir
err = file.MkdirAll(cacheDir, 0700)
if err != nil {
return nil, fmt.Errorf("disk handler mkdir failed: %v", err)
}
dh = &diskHandler{
cacheDir: cacheDir,
billyFS: h.billyFS,
write: dh.diskCacheWrite,
read: dh.diskCacheRead,
remove: dh.diskCacheRemove,
suffix: dh.diskCacheSuffix,
metadata: h.vfs.Opt.MetadataExtension,
}
fs.Infof("nfs", "Storing handle cache in %q", dh.cacheDir)
return dh, nil
}
// Convert a path to a hash
func hashPath(fullPath string) []byte {
hash := md5.Sum([]byte(fullPath))
return hash[:]
}
// Convert a handle to a path on disk for the handle
func (dh *diskHandler) handleToPath(fh []byte) (cachePath string) {
fhString := hex.EncodeToString(fh)
if len(fhString) <= 4 {
cachePath = filepath.Join(dh.cacheDir, fhString)
} else {
cachePath = filepath.Join(dh.cacheDir, fhString[0:2], fhString[2:4], fhString)
}
return cachePath
}
// Return true if name represents a metadata file
//
// It returns the underlying path
func (dh *diskHandler) isMetadataFile(name string) (rawName string, found bool) {
if dh.metadata == "" {
return name, false
}
rawName, found = strings.CutSuffix(name, dh.metadata)
return rawName, found
}
// ToHandle takes a file and represents it with an opaque handle to reference it.
// In stateless nfs (when it's serving a unix fs) this can be the device + inode
// but we can generalize with a stateful local cache of handed out IDs.
func (dh *diskHandler) ToHandle(f billy.Filesystem, splitPath []string) (fh []byte) {
dh.mu.Lock()
defer dh.mu.Unlock()
fullPath := path.Join(splitPath...)
// metadata file has file handle of original file
fullPath, isMetadataFile := dh.isMetadataFile(fullPath)
fh = hashPath(fullPath)
cachePath := dh.handleToPath(fh)
cacheDir := filepath.Dir(cachePath)
err := os.MkdirAll(cacheDir, 0700)
if err != nil {
fs.Errorf("nfs", "Couldn't create cache file handle directory: %v", err)
return fh
}
fh, err = dh.write(fh, cachePath, fullPath)
if err != nil {
fs.Errorf("nfs", "Couldn't create cache file handle: %v", err)
return fh
}
// metadata file handle is suffixed with metadataSuffix
if isMetadataFile {
fh = append(fh, metadataSuffix...)
}
return fh
}
// Write the fullPath into cachePath returning the possibly updated fh
func (dh *diskHandler) diskCacheWrite(fh []byte, cachePath string, fullPath string) ([]byte, error) {
return fh, os.WriteFile(cachePath, []byte(fullPath), 0600)
}
var (
errStaleHandle = &nfs.NFSStatusError{NFSStatus: nfs.NFSStatusStale}
)
// Test to see if a fh is a metadata handle and if so return the underlying handle
func (dh *diskHandler) isMetadataHandle(fh []byte) (isMetadata bool, newFh []byte, err error) {
if dh.metadata == "" {
return false, fh, nil
}
suffix := dh.suffix(fh)
if len(suffix) == 0 {
// OK
return false, fh, nil
} else if bytes.Equal(suffix, metadataSuffix) {
return true, fh[:len(fh)-len(suffix)], nil
}
fs.Errorf("nfs", "Bad file handle suffix %X", suffix)
return false, nil, errStaleHandle
}
// FromHandle converts from an opaque handle to the file it represents
func (dh *diskHandler) FromHandle(fh []byte) (f billy.Filesystem, splitPath []string, err error) {
dh.mu.RLock()
defer dh.mu.RUnlock()
isMetadata, fh, err := dh.isMetadataHandle(fh)
if err != nil {
return nil, nil, err
}
cachePath := dh.handleToPath(fh)
fullPathBytes, err := dh.read(fh, cachePath)
if err != nil {
fs.Errorf("nfs", "Stale handle %q: %v", cachePath, err)
return nil, nil, errStaleHandle
}
if isMetadata {
fullPathBytes = append(fullPathBytes, []byte(dh.metadata)...)
}
splitPath = strings.Split(string(fullPathBytes), "/")
return dh.billyFS, splitPath, nil
}
// Read the contents of (fh, cachePath)
func (dh *diskHandler) diskCacheRead(fh []byte, cachePath string) ([]byte, error) {
return os.ReadFile(cachePath)
}
// Invalidate the handle passed - used on rename and delete
func (dh *diskHandler) InvalidateHandle(f billy.Filesystem, fh []byte) error {
dh.mu.Lock()
defer dh.mu.Unlock()
isMetadata, fh, err := dh.isMetadataHandle(fh)
if err != nil {
return err
}
if isMetadata {
// Can't invalidate a metadata handle as it is synthetic
return nil
}
cachePath := dh.handleToPath(fh)
err = dh.remove(fh, cachePath)
if err != nil {
fs.Errorf("nfs", "Failed to remove handle %q: %v", cachePath, err)
}
return nil
}
// Remove the (fh, cachePath) file
func (dh *diskHandler) diskCacheRemove(fh []byte, cachePath string) error {
return os.Remove(cachePath)
}
// Return a suffix for the file handle or nil
func (dh *diskHandler) diskCacheSuffix(fh []byte) []byte {
if len(fh) <= md5.Size {
return nil
}
return fh[md5.Size:]
}
// HandleLimit exports how many file handles can be safely stored by this cache.
func (dh *diskHandler) HandleLimit() int {
return math.MaxInt
}