This tutorial walks through the smallest useful NFS server you can build with Tahr: a static, in-memory tree of files served read-only. By the end you'll be able to mount it with your operating system's own NFS client.
The complete, tested code lives in the repository at
test/support/readonly_fs.ex —
what follows is that file, piece by piece.
The moving parts
An NFS server built with this library is just three things:
- A
Tahr.Mount.Backend— decides which paths clients may mount and hands out the root file handle for each. - A
Tahr.NFSv3.Backend— answers the filesystem procedures: GETATTR, LOOKUP, READ, READDIR, and friends. - A
Tahr.RPC.Server— the TCP listener. You hand it a program registry mapping ONC RPC program numbers to handlers, and the library takes care of the wire protocol: XDR, record marking, the portmapper, AUTH_SYS.
programs = %{
100_005 => %{3 => Tahr.Mount.Handler.with_backend(MyMountBackend)},
100_003 => %{3 => Tahr.NFSv3.Handler.with_backend(MyNFSBackend)}
}
children = [
{Tahr.RPC.Server, port: 2049, programs: programs}
]100005 is the MOUNT program, 100003 is NFS itself, both version 3.
with_backend/1 binds your backend module into the stateless
handler. The portmapper (program 100000) is always registered for
you so clients can discover the server.
Our filesystem is a compile-time map of fileid => node, so every
callback is a pure lookup — no processes, no state to manage.
Step 1: model the tree
defmodule ReadonlyFS do
@moduledoc """
A tiny read-only filesystem served over NFSv3.
"""
alias Tahr.NFSv3.Filehandle
alias Tahr.NFSv3.Types.{Fattr3, Nfstime3}
@vol_id "readonly-fs-v001"
@fsid 0xFE5_0001
@root_fileid 1
@modification_time %Nfstime3{seconds: 1_767_225_600, nseconds: 0}
@tree %{
1 => %{
type: :dir,
mode: 0o755,
children: %{"hello.txt" => 2, "docs" => 3}
},
2 => %{
type: :reg,
mode: 0o644,
content: "Hello from Tahr!\n"
},
3 => %{
type: :dir,
mode: 0o755,
children: %{"notes.txt" => 4}
},
4 => %{
type: :reg,
mode: 0o644,
content: "Static content, served over NFSv3.\n"
}
}
endA flat map of fileid => node is enough: directories carry a
children map from name to fileid. The root is fileid 1, and it
contains one file and one subdirectory.
File handles
NFS file handles are opaque blobs of up to 64 bytes that the client
hands back to you on every call — you choose what goes in them.
Tahr.NFSv3.Filehandle packs a 16-byte volume id, a 64-bit
fileid, and a 32-bit generation counter into 29 bytes, which is all
we need:
def vol_id, do: @vol_id
def root_handle, do: handle_for(@root_fileid)
def handle_for(fileid), do: Filehandle.encode(@vol_id, fileid)
def fetch(fhandle) do
with {:ok, decoded} <- Filehandle.decode(fhandle),
true <- Filehandle.same_volume?(decoded, @vol_id),
{:ok, node} <- Map.fetch(@tree, decoded.fileid) do
{:ok, decoded.fileid, node}
else
_ -> {:error, :stale}
end
endEvery callback starts with fetch/1. Handles that fail to decode,
belong to a different volume, or name a forgotten fileid are refused
with NFS3ERR_STALE — the client drops its cache and re-resolves.
Attributes
GETATTR and most other replies carry an fattr3 — POSIX-style
metadata. Note that ctime means metadata-change time, not
creation time, and for a static tree one fixed timestamp everywhere
is honest:
def attr(fileid, node) do
size = size_of(node)
%Fattr3{
type: node.type,
mode: node.mode,
nlink: nlink(node),
uid: 0,
gid: 0,
size: size,
used: size,
fsid: @fsid,
fileid: fileid,
atime: @modification_time,
mtime: @modification_time,
ctime: @modification_time
}
end(nlink/1 and size_of/1 are two-line helpers — directories claim
two links, files one; only regular files have a size.)
Step 2: the Mount backend
The MOUNT protocol is how a client turns a path like /export into
a root file handle. The behaviour has two required callbacks; the
mount-bookkeeping callbacks are optional and we skip them:
defmodule ReadonlyFS.MountBackend do
@behaviour Tahr.Mount.Backend
alias Tahr.Mount.Types.ExportNode
@export_path "/export"
@auth_sys 1
@impl true
def resolve(@export_path, _ctx), do: {:ok, ReadonlyFS.root_handle(), [@auth_sys]}
def resolve(_path, _ctx), do: {:error, :noent}
@impl true
def list_exports(_ctx), do: [%ExportNode{dir: @export_path, groups: []}]
endresolve/2 returns the root handle plus the list of acceptable RPC
auth flavours (1 is AUTH_SYS). Anything else gets
MOUNT3ERR_NOENT. list_exports/1 backs the EXPORT procedure —
showmount -e.
Step 3: the NFSv3 backend — read procedures
The Tahr.NFSv3.Backend behaviour has 21 callbacks, one per
NFSv3 procedure. Take them in three groups.
Object lookup and attributes
@impl true
def getattr(fhandle, _auth, _ctx) do
case ReadonlyFS.fetch(fhandle) do
{:ok, fileid, node} -> {:ok, ReadonlyFS.attr(fileid, node)}
{:error, status} -> {:error, status}
end
endEvery callback receives the file handle, the decoded credential
(%Tahr.RPC.Auth.Sys{} or %Auth.None{}), and an opaque ctx
map carrying the RPC envelope (including ctx.peer, the client's IP
address, if you want per-export host filtering).
ACCESS answers "which of these permission bits will you grant?" —
RFC 1813's flags are READ 0x01, LOOKUP 0x02, MODIFY 0x04,
EXTEND 0x08, DELETE 0x10, EXECUTE 0x20. A read-only server
grants only the read-ish subset:
@access_read 0x0001
@access_lookup 0x0002
@access_execute 0x0020
@grantable bor(bor(@access_read, @access_lookup), @access_execute)
@impl true
def access(fhandle, requested_mask, _auth, _ctx) do
case ReadonlyFS.fetch(fhandle) do
{:ok, fileid, node} -> {:ok, band(requested_mask, @grantable), ReadonlyFS.attr(fileid, node)}
{:error, status} -> {:error, status, nil}
end
endMind the error shapes — they differ per callback because the RFC
attaches attributes to different replies. GETATTR errors are bare
{:error, status}; ACCESS errors carry a post_op_attr (or nil)
as a third element. Each callback's @doc spells out its shape.
LOOKUP is the workhorse — name to handle within a directory:
@impl true
def lookup(dir_fhandle, name, _auth, _ctx) do
case ReadonlyFS.fetch(dir_fhandle) do
{:ok, dir_id, %{type: :dir} = dir} -> lookup_child(dir_id, dir, name)
{:ok, _fileid, _node} -> {:error, :notdir, nil}
{:error, status} -> {:error, status, nil}
end
end
defp lookup_child(dir_id, dir, name) do
case ReadonlyFS.fetch_child(dir, name) do
{:ok, fileid, node} ->
{:ok, ReadonlyFS.handle_for(fileid), ReadonlyFS.attr(fileid, node),
ReadonlyFS.attr(dir_id, dir)}
{:error, status} ->
{:error, status, ReadonlyFS.attr(dir_id, dir)}
end
endNote the three-element success tuple: the child's handle, the
child's attributes, and the directory's attributes — and that even
a NOENT reply carries the directory's attributes so the client can
refresh its cache.
Reading file content
READ returns a map with a lazy chunk enumerator, an EOF flag, and a post-op attribute. The handler streams the chunks straight to the socket, so a real backend should never materialise a whole file — our static files are already in memory, so a one-element list is honest here:
@impl true
def read(fhandle, offset, count, _auth, _ctx) do
case ReadonlyFS.fetch(fhandle) do
{:ok, fileid, %{type: :reg, content: content} = node} ->
size = byte_size(content)
data = binary_part(content, min(offset, size), min(count, size - min(offset, size)))
{:ok,
%{
data: [data],
eof: offset + byte_size(data) >= size,
post_op: ReadonlyFS.attr(fileid, node)
}}
{:ok, _fileid, _node} ->
{:error, :isdir, nil}
{:error, status} ->
{:error, status, nil}
end
endListing directories
READDIR and READDIRPLUS paginate with an opaque cookie per entry —
you pick the encoding, the client just hands it back. A cookie
verifier lets the server spot a directory that changed
mid-pagination: return {:error, :bad_cookie, attr} and the client
restarts from cookie 0. Our tree never changes, so a constant
verifier and position-index cookies are enough:
@cookieverf <<0::64>>
@impl true
def readdir(fhandle, cookie, verf, _count, _auth, _ctx) do
with_dir(fhandle, fn dir_id, dir ->
with :ok <- check_verf(cookie, verf, dir_id, dir) do
{:ok, entries_after(dir, cookie), @cookieverf, true, ReadonlyFS.attr(dir_id, dir)}
end
end)
end
defp entries_after(dir, cookie) do
dir
|> ReadonlyFS.entries()
|> Enum.drop_while(fn {_id, _name, c} -> c <= cookie end)
endEntries are {fileid, name, cookie} tuples; the handler splices in
. and .. for you. We ignore the client's count budget and
return everything with eof: true — fine for tiny directories; a
real backend must stop when the next entry would blow the budget.
READDIRPLUS is identical except each entry also carries the child's
attributes and handle.
Server introspection
FSSTAT, FSINFO, and PATHCONF describe the filesystem to the client — capacity, transfer sizes, feature flags. Static answers are fine:
@impl true
def fsinfo(fhandle, _auth, _ctx) do
case ReadonlyFS.fetch(fhandle) do
{:ok, fileid, node} ->
reply = %{
rtmax: 65_536,
rtpref: 32_768,
rtmult: 4_096,
wtmax: 65_536,
wtpref: 32_768,
wtmult: 4_096,
dtpref: 8_192,
maxfilesize: 1_048_576,
time_delta: %Nfstime3{seconds: 0, nseconds: 1_000_000},
properties: @fsf3_homogeneous
}
{:ok, reply, ReadonlyFS.attr(fileid, node)}
{:error, status} ->
{:error, status, nil}
end
endproperties is an OR of RFC 1813's FSF3_* flags — 0x01 LINK,
0x02 SYMLINK, 0x08 HOMOGENEOUS, 0x10 CANSETTIME. A read-only
tree advertises only HOMOGENEOUS. READLINK we refuse with
NFS3ERR_INVAL — there are no symlinks in this tree.
Step 4: refuse mutations
The remaining eleven callbacks all exist, because the behaviour
requires them — they just say no. NFS3ERR_ROFS is exactly what a
real read-only filesystem returns:
@impl true
def setattr(_fhandle, _sattr, _guard_ctime, _auth, _ctx), do: {:error, :rofs}
@impl true
def create(_dir, _name, _mode, _auth, _ctx), do: {:error, :rofs}
@impl true
def write(_fhandle, _offset, _data, _stable, _auth, _ctx), do: {:error, :rofs}
# …and the same for mkdir, remove, rmdir, symlink, mknod, link,
# rename, and commit.Step 5: wire the supervision tree
defmodule MyApp.Application do
use Application
@impl true
def start(_type, _args) do
programs = %{
100_005 => %{3 => Tahr.Mount.Handler.with_backend(ReadonlyFS.MountBackend)},
100_003 => %{3 => Tahr.NFSv3.Handler.with_backend(ReadonlyFS.NFSBackend)}
}
children = [
{Tahr.RPC.Server, port: 2049, programs: programs}
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
endStep 6: mount it
The library's portmapper answers on the same port as everything else, so tell the client where to look (rather than the rpcbind it would normally consult on port 111):
sudo mount -t nfs -o vers=3,tcp,port=2049,mountport=2049 \
127.0.0.1:/export /mnt/readonly
cat /mnt/readonly/hello.txt
# Hello from Tahr!
ls -la /mnt/readonly/docs
# notes.txt
Where next
This tree can't change — every write bounces off NFS3ERR_ROFS.
The read-write tutorial builds a mutable
filesystem with an Agent, real WRITE/CREATE/RENAME support, and
the weak-cache-consistency data the kernel expects — which is also
the Tahr.InMemory reference backend that mix tahr.serve
runs.