Files
rclone/cmd/serve/nfs/handler.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

312 lines
8.5 KiB
Go

//go:build unix
package nfs
import (
"context"
"fmt"
"net"
"os"
"path"
"strings"
"github.com/go-git/go-billy/v5"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/log"
"github.com/rclone/rclone/vfs"
"github.com/willscott/go-nfs"
)
// Handler returns a NFS backing that exposes a given file system in response to all mount requests.
type Handler struct {
vfs *vfs.VFS
opt Options
billyFS *FS
Cache
}
// NewHandler creates a handler for the provided filesystem
func NewHandler(ctx context.Context, vfs *vfs.VFS, opt *Options) (handler nfs.Handler, err error) {
ci := fs.GetConfig(ctx)
h := &Handler{
vfs: vfs,
opt: *opt,
billyFS: &FS{vfs: vfs},
}
h.opt.HandleLimit = h.opt.Limit()
h.Cache, err = h.getCache()
if err != nil {
return nil, fmt.Errorf("failed to make cache: %w", err)
}
var level nfs.LogLevel
switch {
case ci.LogLevel >= fs.LogLevelDebug: // Debug level, needs -vv
level = nfs.TraceLevel
case ci.LogLevel >= fs.LogLevelInfo: // Transfers, needs -v
level = nfs.InfoLevel
case ci.LogLevel >= fs.LogLevelNotice: // Normal logging, -q suppresses
level = nfs.WarnLevel
case ci.LogLevel >= fs.LogLevelError: // Error - can't be suppressed
level = nfs.ErrorLevel
default:
level = nfs.WarnLevel
}
nfs.SetLogger(&logger{level: level})
return h, nil
}
// Mount backs Mount RPC Requests, allowing for access control policies.
//
// The requested Dirpath is interpreted as an absolute path within the VFS.
// path.Clean is used to normalise it and to neutralise any ".." segments,
// so the result is always within the VFS root. If the cleaned path is the
// VFS root the shared root filesystem is returned. Otherwise the path is
// looked up and must be a plain directory (not a regular file, symlink or
// other special node).
func (h *Handler) Mount(ctx context.Context, conn net.Conn, req nfs.MountRequest) (status nfs.MountStatus, hndl billy.Filesystem, auths []nfs.AuthFlavor) {
auths = []nfs.AuthFlavor{nfs.AuthFlavorNull}
cleaned := path.Clean("/" + string(req.Dirpath))
if cleaned == "/" {
return nfs.MountStatusOk, h.billyFS, auths
}
node, err := h.vfs.Stat(cleaned)
if err != nil {
fs.Infof("nfs", "Mount of %q rejected: %v", cleaned, err)
return nfs.MountStatusErrNoEnt, h.billyFS, auths
}
if node.Mode().Type() != os.ModeDir {
fs.Infof("nfs", "Mount of %q rejected: not a plain directory (mode %v)", cleaned, node.Mode())
return nfs.MountStatusErrNotDir, h.billyFS, auths
}
fs.Infof("nfs", "Mounting subpath %q", cleaned)
return nfs.MountStatusOk, h.billyFS.subFS(cleaned), auths
}
// Change provides an interface for updating file attributes.
func (h *Handler) Change(fs billy.Filesystem) billy.Change {
if c, ok := fs.(billy.Change); ok {
return c
}
return nil
}
// FSStat provides information about a filesystem.
func (h *Handler) FSStat(ctx context.Context, f billy.Filesystem, s *nfs.FSStat) error {
total, _, free := h.vfs.Statfs()
s.TotalSize = uint64(total)
s.FreeSize = uint64(free)
s.AvailableSize = uint64(free)
return nil
}
// 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 (h *Handler) ToHandle(f billy.Filesystem, s []string) (b []byte) {
defer log.Trace("nfs", "path=%q", s)("handle=%X", &b)
return h.Cache.ToHandle(f, s)
}
// FromHandle converts from an opaque handle to the file it represents
func (h *Handler) FromHandle(b []byte) (f billy.Filesystem, s []string, err error) {
defer log.Trace("nfs", "handle=%X", b)("path=%q, err=%v", &s, &err)
return h.Cache.FromHandle(b)
}
// HandleLimit exports how many file handles can be safely stored by this cache.
func (h *Handler) HandleLimit() int {
return h.Cache.HandleLimit()
}
// InvalidateHandle invalidates the handle passed - used on rename and delete
func (h *Handler) InvalidateHandle(f billy.Filesystem, b []byte) (err error) {
defer log.Trace("nfs", "handle=%X", b)("err=%v", &err)
return h.Cache.InvalidateHandle(f, b)
}
// Limit overrides the --nfs-cache-handle-limit value if out-of-range
func (o *Options) Limit() int {
if o.HandleLimit < 0 {
return 1000000
}
if o.HandleLimit <= 5 {
return 5
}
return o.HandleLimit
}
// OnUnmountFunc registers a function to call when externally unmounted
var OnUnmountFunc func()
func onUnmount() {
fs.Infof(nil, "unmount detected")
if OnUnmountFunc != nil {
OnUnmountFunc()
}
}
// logger handles go-nfs logs and reroutes them to rclone's logging system
type logger struct {
level nfs.LogLevel
}
// logPrint intercepts go-nfs logs and calls rclone's log system instead
func (l *logger) logPrint(level fs.LogLevel, args ...any) {
fs.LogPrintf(level, "nfs", "%s", fmt.Sprint(args...))
}
// logPrintf intercepts go-nfs logs and calls rclone's log system instead
func (l *logger) logPrintf(level fs.LogLevel, format string, args ...any) {
fs.LogPrintf(level, "nfs", format, args...)
}
// Debug reroutes go-nfs Debug messages to Intercept
func (l *logger) Debug(args ...any) {
if l.level < nfs.DebugLevel {
return
}
l.logPrint(fs.LogLevelDebug, args...)
}
// Debugf reroutes go-nfs Debugf messages to logPrintf
func (l *logger) Debugf(format string, args ...any) {
if l.level < nfs.DebugLevel {
return
}
l.logPrintf(fs.LogLevelDebug, format, args...)
}
// Error reroutes go-nfs Error messages to Intercept
func (l *logger) Error(args ...any) {
if l.level < nfs.ErrorLevel {
return
}
l.logPrint(fs.LogLevelError, args...)
}
// Errorf reroutes go-nfs Errorf messages to logPrintf
func (l *logger) Errorf(format string, args ...any) {
if l.level < nfs.ErrorLevel {
return
}
l.logPrintf(fs.LogLevelError, format, args...)
}
// Fatal reroutes go-nfs Fatal messages to Intercept
func (l *logger) Fatal(args ...any) {
if l.level < nfs.FatalLevel {
return
}
l.logPrint(fs.LogLevelError, args...)
}
// Fatalf reroutes go-nfs Fatalf messages to logPrintf
func (l *logger) Fatalf(format string, args ...any) {
if l.level < nfs.FatalLevel {
return
}
l.logPrintf(fs.LogLevelError, format, args...)
}
// GetLevel returns the nfs.LogLevel
func (l *logger) GetLevel() nfs.LogLevel {
return l.level
}
// Info reroutes go-nfs Info messages to Intercept
func (l *logger) Info(args ...any) {
if l.level < nfs.InfoLevel {
return
}
l.logPrint(fs.LogLevelInfo, args...)
}
// Infof reroutes go-nfs Infof messages to logPrintf
func (l *logger) Infof(format string, args ...any) {
if l.level < nfs.InfoLevel {
return
}
l.logPrintf(fs.LogLevelInfo, format, args...)
}
// Panic reroutes go-nfs Panic messages to Intercept
func (l *logger) Panic(args ...any) {
if l.level < nfs.PanicLevel {
return
}
l.logPrint(fs.LogLevelError, args...)
}
// Panicf reroutes go-nfs Panicf messages to logPrintf
func (l *logger) Panicf(format string, args ...any) {
if l.level < nfs.PanicLevel {
return
}
l.logPrintf(fs.LogLevelError, format, args...)
}
// ParseLevel parses the nfs.LogLevel
func (l *logger) ParseLevel(level string) (nfs.LogLevel, error) {
return nfs.Log.ParseLevel(level)
}
// Print reroutes go-nfs Print messages to Intercept
func (l *logger) Print(args ...any) {
if l.level < nfs.InfoLevel {
return
}
l.logPrint(fs.LogLevelInfo, args...)
}
// Printf reroutes go-nfs Printf messages to Intercept
func (l *logger) Printf(format string, args ...any) {
if l.level < nfs.InfoLevel {
return
}
l.logPrintf(fs.LogLevelInfo, format, args...)
}
// SetLevel sets the nfs.LogLevel
func (l *logger) SetLevel(level nfs.LogLevel) {
l.level = level
}
// Trace reroutes go-nfs Trace messages to Intercept
func (l *logger) Trace(args ...any) {
if l.level < nfs.DebugLevel {
return
}
l.logPrint(fs.LogLevelDebug, args...)
}
// Tracef reroutes go-nfs Tracef messages to logPrintf
func (l *logger) Tracef(format string, args ...any) {
// FIXME BODGE ... the real fix is probably https://github.com/willscott/go-nfs/pull/28
// This comes from `Log.Tracef("request: %v", w.req)` in conn.go
// DEBUG : nfs: request: RPC #3285799202 (mount.Umnt)
argsS := fmt.Sprint(args...)
if strings.Contains(argsS, "mount.Umnt") {
onUnmount()
}
if l.level < nfs.DebugLevel {
return
}
l.logPrintf(fs.LogLevelDebug, format, args...)
}
// Warn reroutes go-nfs Warn messages to Intercept
func (l *logger) Warn(args ...any) {
if l.level < nfs.WarnLevel {
return
}
l.logPrint(fs.LogLevelNotice, args...)
}
// Warnf reroutes go-nfs Warnf messages to logPrintf
func (l *logger) Warnf(format string, args ...any) {
if l.level < nfs.WarnLevel {
return
}
l.logPrintf(fs.LogLevelNotice, format, args...)
}