Serving a read-only filesystem

Copy Markdown View Source

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:

  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

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:

  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:

  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: []}]
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

  @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:

  @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:

  @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:

  @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:

  @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:

  @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:

  @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
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):

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.