Current section
Files
Jump to
Current section
Files
documentation/tutorials/read-only-server.md
# Serving a read-only filesystem
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`](https://harton.dev/james/tahr/src/branch/main/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:
1. **A `Tahr.Mount.Backend`** — decides which paths clients may
mount and hands out the root file handle for each.
2. **A `Tahr.NFSv3.Backend`** — answers the filesystem
procedures: GETATTR, LOOKUP, READ, READDIR, and friends.
3. **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
```elixir
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"
}
}
end
```
A 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:
```elixir
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
end
```
Every 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:
```elixir
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:
```elixir
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: []}]
end
```
`resolve/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
```elixir
@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
end
```
Every 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:
```elixir
@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
end
```
Mind 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:
```elixir
@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
end
```
Note 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:
```elixir
@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
end
```
### Listing 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:
```elixir
@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)
end
```
Entries 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:
```elixir
@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
end
```
`properties` 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:
```elixir
@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
```elixir
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
end
```
## Step 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):
```bash
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](read-write-server.html) 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.