Getting Started with the NFSServer component


Introduction

The NFSServer component implements an NFS 4.0 server, providing a simple way to serve files without a kernel mode driver. The NFSServer component is available for all supported platforms but is particularly useful in macOS because kernel mode driver installation can be challenging.

Starting the Server

To begin, call StartListening to start listening for incoming connections. The component will listen on the interface defined by LocalHost and LocalPort. For example:

nfsserver.LocalHost = "LocalHost"; nfsserver.LocalPort = 2049; // default nfsserver.StartListening(); while (nfsserver.Listening) { nfsserver.DoEvents(); }

StopListening may be called to stop listening for incoming connections. Shutdown may be called to stop listening for incoming connections and to disconnect all existing connections

Handling Connections

Once listening, the component can accept (or reject) incoming connections. Incoming connection details are first available through the ConnectionRequest event. Here, the connection's originating address and port can be queried. By default, the component will accept all incoming connections, but this behavior can be overridden within this event.

Once a connection is complete, the Connected event will fire. This event will fire if a connection succeeds or fails. If successful, the event will fire with a StatusCode of 0. A non-zero value indicates the connection was unsuccessful, and the Description parameter will contain relevant details.

After a successful connection, relevant connection-specific details will be available within the Connections collection. Each connection will be assigned a unique ConnectionId which may be used to access these details.

To manually disconnect a connected client, call the Disconnect method and pass the ConnectionId. After a connection has disconnected, the Disconnected event will fire. In the case a connection ends and an error is encountered, the StatusCode and Description parameters will contain relevant details regarding the error. Once disconnected, the connection will be removed from the Connections collection.

Security

The component can support several security mechanisms to be used by incoming client connections. By default, the component enables system authentication as specific by the SecurityMechanism property.

System authentication (in addition to no authentication), however, is typically recommended only for use on single-user machines. As such, the component supports additional, more secure mechanisms, utilizing the GSS-API, specifically with Kerberos v5 (also known as RPCSEC_GSS using Kerberos v5).

To enable this support, the SecurityMechanism property must be set to include one (or any combination) of the following values: krb5, krb5i, and krb5p.

It is important to note the differences among the listed Kerberos security mechanisms. If krb5 is utilized, Kerberos will be used only to perform client authentication.

If krb5i is utilized, Kerberos will be used to perform client authentication and integrity protection, ensuring that incoming and outgoing packets are untampered. Packets will remain unencrypted, however, making sensitive data potentially visible to anyone monitoring the network.

If krb5p is utilized, Kerberos will be used to perform client authentication, integrity protection, and packet encryption, making this the most secure option.

When specifying krb5, krb5i, or krb5p, the KeytabFile must be configured with the path to a Kerberos Keytab file containing the necessary server credentials. For more information on obtaining a keytab file and using Kerberos with IPWorks NFS, please refer to the article here. See the following simple example:

nfsserver.SecurityMechanism = "krb5,krb5i,krb5p"; // enable RPCSEC_GSS exclusively nfsserver.KeytabFile = "C:\\nfsserver\\krb5.keytab"; nfsserver.StartListening();

Handling Events

The NFSServer component hides most of the complexities involved; the following sections discuss the primary considerations to take into account. Most of the events of the component must be handled for the filesystem to function properly. Many of the listed events expose a Result parameter, which communicates the operation's success (or failure) to the component and connection. This parameter is always 0 (NFS4_OK) when relevant events fire. If the event, or operation, cannot be handled successfully, this parameter should be set to a non-zero value. Possible Result codes and their descriptions are defined in RFC 7530, section 13.

Please refer to the documentation for more specific information about each event.

Create and Open Files

The Open event fires when a client attempts to create or open a file. This event is not applicable to directories; directory operations are handled separately via MkDir, ReadDir, and RmDir. Your application should create or open the requested file according to the OpenType and CreateMode parameters.

FileContext

The FileContext parameter allows your application to store a handle or other information related to the file. This value is shared among all connections for the same file and is passed to subsequent events (Read, Write, Truncate, etc.), enabling efficient handling of further operations.

When the file is not yet opened, FileContext will initially be IntPtr.Zero (or null in some languages). Your application may assign a handle or context object here to avoid reopening the file for later operations.

OwnerId and Share Reservations

The OwnerId identifies the owner opening the file. Each owner maintains its own share reservations, independent of other owners, even on the same connection. Because a client may send additional Open requests for a file it has already opened, the application must handle potential upgrades or downgrades of existing share reservations.

To handle these requests correctly, track the requested ShareAccess and ShareDeny values for each OwnerId. When processing an additional open request from the same owner, evaluate whether an upgrade, downgrade, or no change is necessary:

  • If Downgrade is false, the owner may be requesting an upgrade. Expand existing reservations only if the new request grants additional access beyond the current reservations.
  • If Downgrade is true, the owner is downgrading access, and their reservations should be reduced to the newly requested ShareAccess and ShareDeny values.

Any conflicts with share reservations held by other owners should result in NFS4ERR_SHARE_DENIED. This per-owner tracking ensures that multiple clients, or multiple owners on the same connection, can safely open the same file concurrently while respecting access and denial rules. For an example of per-owner reservation tracking, please see the sample included with the project.

File Creation and Attributes

When OpenType is OPEN4_CREATE and CreateMode is UNCHECKED4 or GUARDED4, the client may provide file attributes. These attributes include Size, ATime, MTime, User, Group, and Mode. Default values indicate that the client does not explicitly specify a value:

Attribute Default
Size -1
ATime DateTime.UnixEpoch (.NET, in the local system's time), new Date(0) (Java), or 0 (other)
MTime DateTime.UnixEpoch (.NET, in the local system's time), new Date(0) (Java), or 0 (other)
User ""
Group ""
Mode -1

If the file does not exist, apply any specified attributes. If the file exists and CreateMode is UNCHECKED4, apply attributes only if Size = 0, which indicates truncation. Lastly, if an attribute cannot be applied, reset it to its default value to indicate this to the client.

Opening the File

After managing FileContext, per-owner share reservations, and file attributes, your application should open or create the file according to the requested OpenType and CreateMode.

Use the existing FileContext if available to avoid reopening the file. Ensure that the requested ShareAccess and ShareDeny values do not conflict with existing reservations, and verify that the owner has the necessary permissions. Respect any filesystem restrictions, such as read-only mode, and handle situations like missing files or attempts to create duplicates appropriately.

By following these steps, your application can safely handle multiple owners, concurrent opens, upgrades and downgrades, attribute application, and proper management of the file lifecycle. For a complete working example including full error handling and per-owner share management, refer to the sample project included with this toolkit.

nfsserver.OnOpen += (o, e) => { string path = "C:\\NFSRootDir" + e.Path; FileStream fs = null; FileAccess shareAccess = (FileAccess)e.ShareAccess; // ShareAccess correlates directly with FileAccess FileShare shareDeny = 0; // ShareDeny does not correlate directly with FileShare, so let's translate ShareDeny to FileShare switch (e.ShareDeny) { case OPEN4_SHARE_DENY_BOTH: { shareDeny = FileShare.None; // Allow no access by other clients break; } case OPEN4_SHARE_DENY_WRITE: { shareDeny = FileShare.Read; // Allow read access by other clients break; } case OPEN4_SHARE_DENY_READ: { shareDeny = FileShare.Write | FileShare.Delete; // Allow write access and deletion by other clients break; } default: { // Default OPEN4_SHARE_DENY_NONE shareDeny = FileShare.ReadWrite | FileShare.Delete; // Allow read and write access and deletion by other clients break; } } FileMode createMode = FileMode.Open; if (e.OpenType == OPEN4_NOCREATE) { // The client wishes to open the file without creating it. If it doesn't exist, return NFS4ERR_NOENT, otherwise open with FileMode.Open. if (!File.Exists(e.Path)) { e.Result = NFS4ERR_NOENT; return; } } else { // The client wishes to create a file, with the specified CreateMode switch (e.CreateMode) { case UNCHECKED4: { createMode = FileMode.Create; // If a duplicate exists, no error is returned break; } default: { // Implies e.CreateMode == GUARDED4 || EXCLUSIVE4. If a duplicate exists, NFS4ERR_EXIST is returned if (File.Exists(path)) { e.Result = NFS4ERR_EXIST; return; } // Otherwise, proceed with creating the file. createMode = FileMode.CreateNew; break; } } } fs = File.Open(path, createMode, shareAccess, shareDeny); e.FileContext = (IntPtr)GCHandle.Alloc(fs); };

Note: The creation of directories is handled through the MkDir event.

Close Files

The Close event fires when a client requests the closure of a previously opened file, identified by the Path and/or FileContext parameters. The ConnectionId parameter indicates the client performing the operation. The OwnerId parameter identifies the owner of the file handle. Applications should track ownership to manage share reservations correctly.

To handle this event properly, the application should:

  • Release the share reservations for this specific file created during a previous Open operation. This applies only to the Open performed by the given OwnerId and does not affect other owners.
  • Release any byte-range locks currently held for this file by the indicated OwnerId. If locks exist, the application may either:
    • Release all locks and return NFS4_OK, or...
    • Leave the locks and respond with NFS4ERR_LOCKS_HELD, letting the client handle them.
  • Free any resources associated with FileContext and set this parameter to IntPtr.Zero, if applicable.

Listing Directory Contents

The ReadDir event is fired when a client attempts to list the contents of a directory identified by the Path parameter.

To handle this event properly, the application must call the FillDir method for each existing entry within the associated directory. Doing so will buffer the relevant directory entry information to be sent to the client upon return of this event.

When listing a directory, the client will specify a limit on the amount of data (or number of entries) to return in a single response. To handle this, the application should analyze the returned value of each call to FillDir within this event to ensure that the limit is not exceeded. A non-zero return value indicates that the most recent call to FillDir would have caused the application to send more data than the limit specified by the client. In this case, the event should return immediately with a Result of NFS4_OK.

Afterward, the client will send subsequent requests to continue retrieving entries. In these requests, the Cookie parameter will be equal to the cookie value the application specified in the last successful entry provided by FillDir. The cookie value provided in FillDir and the Cookie parameter specified by the client are meaningful only to the server. The cookie values should be interpreted and utilized as a "bookmark" of the directory entry, indicating a point for continuing the directory listing. Please see the documentation for the FillDir for further details.

This event also includes the FileContext parameter. When a directory listing begins, as indicated by a Cookie value of 0, this context may be set. If the directory listing spans multiple ReadDir operations as mentioned earlier, this context may be used again. If all directory entries have been listed, the FileContext should be disposed of before ReadDir returns.

Example 2: Starting or continuing a directory listing

int baseCookie = 0x12345678; nfsserver.OnReadDir += (o, e) => { int dirOffset = 0; // Initial directory offset // Arbitrary base cookie to start at for listing directory entries. // On calls to FillDir, this value will be incremented, so subsequent READDIR operations can resume from a specified cookie. long cookie = baseCookie; // If e.Cookie == 0, we start listing from the beginning of the directory. // Otherwise, we start listing from the offset indicated by this parameter. if (e.Cookie != 0) { offset = e.Cookie - baseCookie + 1; cookie = e.Cookie + 1; } string path = "C:\\NFSRootDir" + e.Path; var entries = Directory.GetFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly); // Iterate through all directory entries. // dirOffset indicates the next entry to be listed given the client's provided Cookie. for (int i = dirOffset; i < entries.Length; i++) { string name = Path.GetFileName(entries[i]); bool isDir = Directory.Exists(entries[i]); int result = 0; if (isDir) { int fileMode = S_IFDIR | S_IRWXU | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH; // Indicates file type (S_IFDIR) and permissions (755) result = nfsserver.FillDir(e.ConnectionId, name, cookie++, fileMode, "OWNER", "OWNER_GROUP", 1, 4096, new DirectoryInfo(path).LastAccessTime, new DirectoryInfo(path).LastWriteTime, new DirectoryInfo(path).CreationTime); } else { int fileMode = S_IFREG | S_IWUSR | S_IRUSR | S_IRGRP | S_IROTH; // Indicates file type (S_IFREG) and permissions (644). result = nfsserver.FillDir(e.ConnectionId, name, cookie++, fileMode, "OWNER", "OWNER_GROUP", 1, FileInfo(path).Length, new FileInfo(path).LastAccessTime, new FileInfo(path).LastWriteTime, new FileInfo(path).CreationTime); } // Return if FillDir returned non-zero value (client's entry limit has been reached) if (result != 0) { // No entries were returned, set Result in this case to alert client if (i == dirOffset) { e.Result = NFS4ERR_TOOSMALL; } return; } } };

File Read/Write Operations

The ConnectionId parameter indicates the client sending the request. The OwnerId parameter identifies the owner requesting the operation. Applications must ensure the owner has the appropriate access according to previously stored share reservations (see Open for details).

Read Event

The Read event fires when a client requests to read data from a file, identified by FileContext and/or Path. The ConnectionId indicates the client, and OwnerId identifies the owner requesting the read. Applications must verify that the owner has appropriate read access according to previously stored share reservations (see Open).

Your application should read up to Count bytes from the specified Offset into Buffer, and then update Count with the number of bytes actually read. The file’s last accessed time should be updated upon a successful read. If the end of the file is reached, set Eof to True. When Count is 0 or Offset exceeds the file size, the operation should succeed without returning any data. If a mandatory byte-range lock conflicts with the requested read, the operation must fail, setting Result to NFS4ERR_LOCKED.

Example 3: Reading data from a file

nfsserver.OnRead += (o, e) => { if (e.Count == 0) { return; } try { IntPtr p = e.FileContext; GCHandle h = (GCHandle)p; FileStream fs = h.Target as FileStream; if (e.Offset >= fs.Length) { e.Count = 0; e.Eof = true; return; } fs.Position = e.Offset; int count = fs.Read(e.BufferB, 0, e.Count); // If EOF was reached, notify the client if (fs.Position == fs.Length) { e.Eof = true; } else { e.Eof = false; } // Return number of bytes read e.Count = c; } catch (Exception ex) { e.Result = NFS4ERR_IO; } };

Write Event

The Write event fires when a client requests to write data to a file. Like Read, the ConnectionId and OwnerId indicate the client and owner, and applications must ensure the owner has write access according to share reservations.

Your application should write the specified Count bytes from Buffer to the file starting at Offset, then update Count to reflect the actual number of bytes written. The Stable parameter specifies the required commitment level: UNSTABLE4 allows data and metadata to be committed later, DATA_SYNC4 requires all data to be committed to stable storage, and FILE_SYNC4 requires all data and filesystem metadata to be committed. The application must meet at least the level requested; returning a weaker level is a protocol violation. If a mandatory byte-range lock conflicts with the write, set Result to NFS4ERR_LOCKED. For writes performed with UNSTABLE4 or DATA_SYNC4, the Commit event may fire later to ensure data is flushed to stable storage.

Both Read and Write events include the FileContext parameter for efficiently tracking file handles and resources, which may have been assigned in a previous Open event.

Example 4: Writing data to a file

nfsserver.OnWrite += (o, e) => { if (e.Count == 0) { return; } try { IntPtr p = e.FileContext; GCHandle h = (GCHandle)p; FileStream fs = h.Target as FileStream; fs.Position = e.Offset; fs.Write(e.BufferB, 0, e.Count); fs.Flush(); // All data was written to disk, indicate this via Stable. e.Stable = FILE_SYNC4; } catch (Exception ex) { e.Result = NFS4ERR_IO; } };

Link support in the NFSServer component is made available through the CreateLink and ReadLink events. When a client attempts to create a link, the CreateLink event will fire, indicating the specific type of link being created through the LinkType parameter (indicating a symbolic link or hard link). Please refer to the following sections for handling the creation of each specific type of link.

When a client attempts to create a symbolic link, the CreateLink event will fire with a LinkType set to 0. In this case, the application should attempt to create a symbolic link pointing to the data specified by the LinkTarget or LinkTargetB parameters. The data specified by the client will need to be returned exactly as provided within the ReadLink event, which fires when a client attempts to read a symbolic link.

In the case of symbolic links, the LinkTarget parameters should not be interpreted by the server. Typically, the link target is a path to some local file; however, this may not always be the case. It is ultimately left up to the client to interpret the data associated with the symbolic link.

In the case of symbolic links, it is also the applications responsibility to return this data to a client when ReadLink fires. Applications should return the data as provided exactly by the client within the CreateLink event. This data may or may not be UTF-8 encoded. In that regard, the data should be treated like the content of a regular file. Please refer to the documentation for additional details regarding CreateLink and ReadLink. See the following simple example to handle symbolic links.

Example 5: Creating and reading symbolic links

``` nfsserver.OnCreateLink += (o, e) => { string linkname = GetRealPath(root, e.Path); if (File.Exists(linkname)) { e.Result = NFS4ERR_NOENT; return; } // Symbolic link if (e.LinkType == 0) { try { // CreateSymbolicLink function from kernel32.dll CreateSymbolicLink(linkname, e.LinkTarget, 0); } catch (Exception ex) { e.Result = NFS4ERR_IO; } } };

nfsserver.OnReadLink += (o, e) => { try { string real = GetRealPath(root, e.Path); // NET 6 FileSystemInfo class contains a LinkTarget property FileSystemInfo fileInfo = new FileInfo(real); byte[] data = System.Text.Encoding.UTF8.GetBytes(fileInfo.LinkTarget); int bytesToRead = data.Length - (int)e.Offset; e.Eof = true; if (bytesToRead > e.Count) { bytesToRead = e.Count; // 1024 bytes, size of e.BufferB e.Eof = false; // ReadLink will fire again to finish copying link content } System.Array.Copy(data, e.Offset, e.BufferB, 0, bytesToRead); e.Count = bytesToRead; } catch (Exception ex) { e.Result = NFS4ERR_IO; } }; ```

When a client attempts to create a symbolic link, the CreateLink event will fire with a LinkType set to 1. In this case, the application should attempt to create a hard link pointing to the file specified by the LinkTarget parameter. Unlike with symbolic links, the LinkTarget parameter should be interpreted by the server, as the path to an existing local file.

To appropriately handle this case, the application should create a hard link at the specified Path that points to the data associated with the file specified by LinkTarget.

Depending on the underlying filesystem, "." and ".." are illegal values for a new object name. In this case, Result should be set to NFS4ERR_BADNAME. Additionally, if the new object name has a length of 0, or does not obey the UTF-8 definition, Result should be set to NFS4ERR_INVAL. See the following simple example to create a hard link.

Example 6: Creating and reading symbolic links

nfsserver.OnCreateLink += (o, e) => { string linkname = GetRealPath(root, e.Path); string target = GetRealPath(root, e.LinkTarget); if (File.Exists(linkname)) { e.Result = NFS4ERR_EXIST; return; } // Hard link if (e.LinkType == 1) { try { // CreateHardLink function from kernel32.dll CreateHardLink(linkname, target, IntPtr.Zero); } catch (Exception ex) { e.Result = NFS4ERR_IO; } } };

Renaming and Deletion

Files and directories are deleted through Unlink and RmDir events, respectively. Although most files contain just one link (main file name), if the file has several hard links, then only the link specified in the parameters of the Unlink must be removed, and the file itself must be deleted only when no more links are pointing to it.

File and directory renaming and moving are atomic operations that must be handled through the Rename event. When renaming a file or directory, the OldPath parameter specifies the original object, or the object to move. The NewPath parameter will specify the target object, or the location where the original object should be moved to.

To appropriately handle the Rename event, the application should determine whether the target object identified by NewPath exists. If the target object does not exist, the application should proceed with the operation and the original object should be renamed. If, however, the target object exists, the application must determine whether the original and target object are compatible (i.e., both objects are either files or directories). If the objects are incompatible, the rename operation should not succeed and an error should be returned through Result.

Assuming the objects are compatible, the behavior will differ depending on whether both objects are files or directories. If both objects are files, the target file should simply be removed and replaced by the original file. If both objects are directories, the target directory should only be removed and replaced assuming it contains no entries. If the target directory contains no entries, the rename operation should succeed. Otherwise, it should fail. See the following detailed example of these cases and applicable errors.

Example 7: Renaming a file or directory

nfsserver.OnRename += (o, e) => { string oldPath = "C:\\NFSRootDir" + e.OldPath; string newPath = "C:\\NFSRootDir" + e.NewPath; // Check if oldPath and newPath refer to the same file. If so, return successfully. if (oldPath.Equals(newPath)) { return; } // Check whether oldPath is a file or directory if (Directory.Exists(oldPath)) { // oldPath is a directory. Check for incompatible rename operation. if (File.Exists(newPath)) { // Incompatible, Directory -> File e.Result = NFS4ERR_EXIST; return; } // Check if target directory exists. If so, check the number of files in this directory. int fileCount = 0; if (Directory.Exists(newPath)) { fileCount = Directory.GetFiles(newPath, "*", SearchOption.TopDirectoryOnly).Length; } // If files exist in the target directory, return error. if (fileCount == 0) { Directory.Move(oldPath, newPath); } else { e.Result = NFS4ERR_EXIST; } } else { // oldPath is a file. Check for incompatible rename operation. if (Directory.Exists(newPath)) { // Incompatible, File -> Directory e.Result = NFS4ERR_EXIST; return; } if (File.Exists(newPath)) { File.Delete(newPath); } File.Move(oldPath, newPath); } };

Modifying Attributes

Changes in object attributes are communicated through the Chmod, Chown, Truncate, and UTime events. These events include the Path parameter and FileContext parameter described earlier.

User and Group Ownership

Chmod fires when a client attempts to modify the permission bits of an object as defined in the UNIX standard sys/stat.h header. Applications should note that the client will not modify the bits associated with the file type.

Chown fires when a client attempts to modify an object's owner attribute, group attribute, or both.

Truncate Event

The Truncate event fires when a client attempts to modify a file’s size (Size), identified by FileContext and/or Path. The ConnectionId indicates the client, and OwnerId identifies the owner performing the truncate. Applications must ensure the owner has write access before applying the size change.

Files do not need to be already opened for a truncate operation. If FileContext is null, no existing handle is available, and the file should be opened or otherwise handled for truncation. If FileContext is set, use the existing handle and verify the owner’s write access against stored share reservations.

The Size parameter contains the desired size to set for the file. This event may also fire when a new object (directory or symbolic link) is created, allowing the client to set the initial size.

UTime Event

The UTime event fires when a client attempts to modify a file’s last access time (ATime), last modification time (MTime), or both. The ConnectionId identifies the client, and OwnerId identifies the owner performing the operation.

Since timestamp changes typically do not require an open file, FileContext may be null. If set, it can be used to track changes associated with a previously opened handle. Applications should verify that the owner has permission to modify the timestamps.

  • ATime contains the desired access time.
  • MTime contains the desired modification time.

If a parameter is set to DateTime.UnixEpoch (in the local system's time), it should be ignored and the corresponding timestamp should not be changed. This event may also fire when a new object (directory or symbolic link) is created, in which case the client may wish to set initial timestamps.

We appreciate your feedback. If you have any questions, comments, or suggestions about this article please contact our support team at support@nsoftware.com.