nfs files provider
What it is
A real NFSv3 server (RFC 1813), backed by the shared virtual filesystem
instead of a real disk. An operating system can mount it, and a file copied
into the mount point is the same tree ftp, sftp and tftp write into -
listed in the Files tab and downloadable over the HTTP API exactly the
same way.
It is built on willscott/go-nfs,
whose backend contract is a go-billy
filesystem rather than the afero.Fs the FTP provider adapts to. fs.go is
the translation layer and does the same job providers/ftp/fs.go does: every
method forwards onto a plugins/files.Session and none of them interprets a
path, because VFS.Resolve is the plugin's one security gate.
What it's for
Reach for this one, not ftp/sftp/tftp, when the thing under test
mounts a filesystem rather than speaking a transfer protocol through a
client library - a backup job, a data pipeline that reads/writes through
ordinary file I/O, anything whose interface to the outside world is open()
and write(), not an FTP or SSH client. The alternative is standing up a real
NFS server or a Dockerized Samba box just to give that one process something
to mount, which is exactly the setup cost this provider exists to remove.
Two things are worth knowing before you reach for it. First, mounting an NFS
share needs root on every operating system - there is no way around this, it
is how NFS mounts work. Second, NFS has no open/close on the wire, so one
logical upload shows up in the event log as a CREATE followed by one
files.upload per WRITE chunk the client chose to send, not one tidy event
per file - deliberate (see "What lands in the event log" below), but a reader
staring at a noisy tab deserves to know why before they assume something is
wrong.
How to test it for real
Booted with TOMMY_NO_UPDATE_CHECK=1 go run . files --nfs-port 2049 (or
tommy serve with the provider enabled). Mounting needs root, and this
was written in a sandbox with no passwordless sudo, so the actual mount
commands below were not executed as part of this pass - they are carried over
from the existing documentation, unverified by this wave. A Linux or macOS
reader with root should be able to run them as written:
# Linux
sudo mkdir -p /mnt/tommy
sudo mount -t nfs -o nfsvers=3,tcp,port=2049,mountport=2049,nolock,noacl 127.0.0.1:/ /mnt/tommy
echo 'it works' | sudo tee /mnt/tommy/hello.txt
sudo umount /mnt/tommy
# macOS
sudo mkdir -p /Volumes/tommy
sudo mount -t nfs -o vers=3,tcp,port=2049,mountport=2049,nolocks 127.0.0.1:/ /Volumes/tommy
What this pass did verify, without root, is that the single port actually
answers both RPC programs a mount needs, dispatched by program number the way
"Mounting: the part you have to know" below describes. A raw ONC RPC v2 NULL
call (RFC 5531) against a live instance on port 12049 got a clean
SUCCESS/accept_stat=0 from both:
prog=100005 vers=3 (MOUNT): reply_stat=0 accept_stat=0
prog=100003 vers=3 (NFS): reply_stat=0 accept_stat=0
That confirms the listener and the program dispatch are live and correct; it
does not exercise LOOKUP/READ/WRITE or the VFS behind them, which is
what an actual mount would. showmount, which ships on both Linux and
macOS, cannot substitute for this because it goes through rpcbind on port 111
to find mountd - exactly the lookup this provider does not answer (see
below) - so it has no way to be pointed at a non-standard port.
For a test that cannot have root at all, libnfs's
nfs-cp/nfs-ls are a userspace client and need none - carried over from the
existing documentation and not installed in this sandbox, so not executed
here either:
echo 'it works' > ./local.txt
nfs-cp ./local.txt 'nfs://127.0.0.1/tommy?version=3&nfsport=2049&mountport=2049'
nfs-ls -l 'nfs://127.0.0.1/tommy?version=3&nfsport=2049&mountport=2049'
Once something has written into the mount, the result reads back the same way every other provider's does:
curl -s http://127.0.0.1:8811/api/v1/files/tree
curl -s http://127.0.0.1:8811/api/v1/files/content/hello.txt
Mounting: the part you have to know
An NFS client normally finds the NFS and mountd services by asking
rpcbind (the portmapper) on port 111. tommy runs no portmapper, and
does not want to: 111 is privileged on every OS tommy runs on, and
registering with the machine's real rpcbind would advertise this fake to
everything on the host. Instead both RPC programs - MOUNT (100005) and NFS
(100003) - are answered on the one TCP port this provider binds, dispatched
on the program number in each request header.
So the client must be told both ports explicitly. With neither given it goes
looking for rpcbind and the mount fails. Linux
nfs(5) and macOS
mount_nfs(8) spell them the same way:
# Linux
sudo mkdir -p /mnt/tommy
sudo mount -t nfs -o nfsvers=3,tcp,port=2049,mountport=2049,nolock,noacl 127.0.0.1:/ /mnt/tommy
echo 'it works' | sudo tee /mnt/tommy/hello.txt
sudo umount /mnt/tommy
# macOS
sudo mkdir -p /Volumes/tommy
sudo mount -t nfs -o vers=3,tcp,port=2049,mountport=2049,nolocks 127.0.0.1:/ /Volumes/tommy
Mounting needs root on every operating system. For a test that cannot have it, libnfs's tools are a userspace NFS client and need none:
echo 'it works' > ./local.txt
nfs-cp ./local.txt 'nfs://127.0.0.1/tommy?version=3&nfsport=2049&mountport=2049'
nfs-ls -l 'nfs://127.0.0.1/tommy?version=3&nfsport=2049&mountport=2049'
The export name (/ above, tommy in the URL) is ignored: there is one
tree and every MOUNT request is answered with it. What the client asked for
is recorded, not checked - provider rule 1.
curl -s http://localhost:8811/api/v1/files/tree
curl -s http://localhost:8811/api/v1/files/content/hello.txt
All of these are Snippets() entries rendered against the live SnippetCtx,
so tommy providers files/nfs prints them with the port this instance
actually bound - 2049 above is only the fallback for when nothing is
running.
Why 2049, unlike the other three
ftp, sftp and tftp all had to move off their real ports (21, 22, 69)
because those are privileged. NFS's real port, 2049, is already above 1024,
so there is no reason to serve this anywhere else and one good reason not to:
a client told port=2049 is a client told the default. The only machine where
it collides is one already running a real nfsd, and --nfs-port moves it.
Note 2049 is the traditional port of the NFS program only. mountd has no
fixed port anywhere - it is normally discovered through rpcbind - which is why
mountport= is passed as well, pointing at the same listener.
What lands in the event log
NFS is stateless: there is no open or close on the wire. A file therefore
arrives as NFSPROC3_CREATE followed by one NFSPROC3_WRITE per chunk the
client chose to send, and each of those commits to the tree and appends its
own files.upload event. A test fixture is one CREATE plus one WRITE; a large
file is one event per chunk.
That is deliberate, and it is the honest translation. Unlike FTP's STOR or
an SFTP upload there is no end-of-transfer signal to hang a single event on -
NFSPROC3_COMMIT exists but go-nfs handles it internally and never reaches
the filesystem backend. Event.Raw.Body names the procedure that caused each
one, so the sequence reads correctly in the UI.
Event.Raw.Transport is "tcp", Event.Raw.PeerAddr and Op.Peer are the
address of the connection that mounted, and Meta.uid / Meta.gid carry
the AUTH_UNIX credential. Op.User is the client's own hostname from that
credential, which is the closest thing to an identity NFS puts on the wire.
The mount connection is the right caveat to know: a kernel client opens one
TCP connection to mountd and another to the NFS program, and only the first
one runs through Handler.Mount. billy's methods take neither a context nor a
connection, so the mounting connection's identity is what every event from
that client carries. Same host, possibly a different source port.
Reads record nothing, the way RETR and SFTP's Read do: the event log is a
record of change and a directory listing is not one.
Path and handle safety
Two separate questions, because NFS addresses objects by handle, not by path.
- Names. Every path a client component contributes is joined by
Join(which only joins and cleans, usingpath, neverfilepath) and then resolved throughVFS.Resolvebefore any node is touched. There is no host filesystem underneath the tree for a traversal to reach, and..clamps at the root the way it does on any rooted filesystem. - Handles. A handle here is a random UUID minted by
go-nfs's caching handler and kept in an LRU. It encodes no path at all, so a handle a client invents, replays or has held past eviction can only be answered withNFS3ERR_STALE. A handle this server did mint names a path this server produced from a successful lookup - and that path is resolved again, through the same single gate, on every operation.
listener_test.go proves both with raw RPC calls the client library would
never make on its own: it cleans a path before splitting it, so a hostile
single component (.., an absolute host path, a name carrying a NUL) has to
be put on the wire by hand.
Ownership and permissions
The VFS has no owners and fixed 0644/0755 bits, but a mounted NFS share is
policed by the client kernel against whatever the server reports. Reporting
uid 0 and 0644 would leave an ordinary process on the client unable to write
to a fake that accepts everything. So the caller is reported as the owner and
the modes are world-writable. There is no access control to model here, and a
fake that answers "permission denied" is useless.
Directories report a link count of two, the way a real one does: a count below
two makes find's leaf optimisation skip subdirectories.
Configuration
[plugins.files.providers.nfs]
enabled = true
port = 2049 # 0 binds an ephemeral port
bind = "127.0.0.1"
# handle_cache = 16384 # live file handles; see below
An absent port means 2049; port = 0 means "bind an ephemeral port",
which is what the test harness uses. --nfs-port is the CLI equivalent and is
the only flag this provider adds: NFS has no login step to pin credentials
for, and handle_cache is a tuning knob no test run needs to flip.
handle_cache matters in both directions. Too small and a client still
walking a directory gets NFS3ERR_STALE, because go-nfs also caps one
READDIR/READDIRPLUS response at half the limit; too large and a
long-running instance holds paths nobody will ask for again. The default is
generous next to the VFS's own 4096-entries-per-directory limit, and anything
below 64 is raised.
Known edges
O_EXCLwithoutO_CREATE.go-nfsopens a file that way when SETATTR carries a new size - evidently meaning "do not create". POSIX leaves that combination undefined and the VFS reads it strictly, as "must not exist", so the adapter drops the flag. Without that, truncating a file over NFS (whatopen(O_TRUNC)becomes on a mount) fails outright. There is a test for it.- Symlinks, hard links and special files are not supported: the VFS has no
concept of them, and inventing one would mean a second way to name a node,
which is exactly what the one-resolver rule exists to prevent.
go-nfsadvertises symlink support in FSINFO regardless - it infers it from the Go interface being present, which billy requires - so a client sees the offer and getsNFS3ERR_NOTSUPPif it takes it. - Nested writes need their parents. Unlike
ftp, which pre-creates missing directories the waycurl --ftp-create-dirsexpects, an NFS client issues its ownMKDIRper level. Nothing is guessed here. go-nfslogs through a package-level global rather than a per-server logger.log.goreplaces it, once per process, with a bridge onto tommy'sslog. Its severities are mapped down deliberately: the library callsErrorffor ordinary client outcomes like a lookup that misses.
Files
provider.go-plugin.Provider/ListenerProvider/AddressableProvider: configuration, description, snippets, and the listener lifecycle.handler.go- thego-nfsHandler: MOUNT,FSSTAT, and the AUTH_UNIX credential decoder. Wrapped in the library's caching handler, which is what mints file handles.fs.go- thebilly.Filesystemadapter over a*files.Session.log.go- thego-nfslogger bridged ontoslog.