feat: full NFSv4.2 server and client in pure Go
Test / test (push) Successful in 2m4s
Release / gates (push) Successful in 2m5s
Release / build (amd64, freebsd) (push) Successful in 1m27s
Release / build (amd64, linux) (push) Successful in 1m22s
Release / build (amd64, netbsd) (push) Successful in 1m19s
Release / build (amd64, openbsd) (push) Successful in 1m20s
Release / build (arm64, darwin) (push) Successful in 1m21s
Release / build (arm64, freebsd) (push) Successful in 1m26s
Release / build (arm64, linux) (push) Successful in 1m25s
Release / build (arm64, netbsd) (push) Successful in 1m31s
Release / build (arm64, openbsd) (push) Successful in 1m27s
Release / build (loong64, linux) (push) Successful in 1m37s
Release / build (riscv64, linux) (push) Successful in 1m21s
Release / release (push) Successful in 40s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-21 18:51:17 +02:00
commit a9b8039ef7
153 changed files with 34403 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package main
import (
"errors"
"flag"
"fmt"
"os"
"sourcedock.dev/petrbalvin/interpres/v2"
)
// A tlsFile is the [tls] table of the configuration file.
type tlsFile struct {
Cert string `toml:"cert"`
Key string `toml:"key"`
}
// An exportFile is one [[export]] table of the configuration file.
type exportFile struct {
Path string `toml:"path"`
ReadOnly bool `toml:"read-only"`
RootSquash bool `toml:"root-squash"`
}
// A fileConfig mirrors the whole configuration file. Every key is
// optional; a key the file leaves out keeps the flag or the built in
// default.
type fileConfig struct {
Listen string `toml:"listen"`
LogOps bool `toml:"log-ops"`
StateDir string `toml:"state-dir"`
MaxConnections int `toml:"max-connections"`
TLS *tlsFile `toml:"tls"`
Exports []exportFile `toml:"export"`
}
// loadConfig reads and validates one configuration file. An empty path
// reads nothing and answers the zero configuration: the file is never
// read unless the operator names it.
func loadConfig(path string) (fileConfig, error) {
var cfg fileConfig
if path == "" {
return cfg, nil
}
data, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
if err := interpres.Unmarshal(data, &cfg, interpres.RejectUnknownFields(true)); err != nil {
if se, ok := errors.AsType[*interpres.SyntaxError](err); ok {
return cfg, fmt.Errorf("%s:%d:%d: %s", path, se.Line, se.Column, se.Msg)
}
return cfg, fmt.Errorf("%s: %v", path, err)
}
if len(cfg.Exports) != 1 {
return cfg, fmt.Errorf("%s: exactly one [[export]] is required, found %d", path, len(cfg.Exports))
}
if cfg.Exports[0].Path == "" {
return cfg, fmt.Errorf("%s: [[export]] names no path", path)
}
return cfg, nil
}
// applyConfig folds the file configuration under the flags: the file is
// the base, every flag the operator passed overrides it. The visited set
// is the flags present on the command line, defaults included.
func applyConfig(cfg fileConfig, fs *flag.FlagSet, set func(string) bool) {
if v := cfg.Listen; v != "" && !set("addr") {
_ = fs.Set("addr", v)
}
if len(cfg.Exports) == 1 && !set("export") {
_ = fs.Set("export", cfg.Exports[0].Path)
if cfg.Exports[0].ReadOnly && !set("ro") {
_ = fs.Set("ro", "true")
}
if cfg.Exports[0].RootSquash && !set("root-squash") {
_ = fs.Set("root-squash", "true")
}
}
if cfg.LogOps && !set("log-ops") {
_ = fs.Set("log-ops", "true")
}
if v := cfg.StateDir; v != "" && !set("state-dir") {
_ = fs.Set("state-dir", v)
}
if v := cfg.MaxConnections; v != 0 && !set("max-connections") {
_ = fs.Set("max-connections", fmt.Sprint(v))
}
if cfg.TLS != nil && !set("tls-cert") && !set("tls-key") {
if cfg.TLS.Cert != "" {
_ = fs.Set("tls-cert", cfg.TLS.Cert)
}
if cfg.TLS.Key != "" {
_ = fs.Set("tls-key", cfg.TLS.Key)
}
}
}
+170
View File
@@ -0,0 +1,170 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package main
import (
"errors"
"flag"
"os"
"path/filepath"
"strings"
"testing"
"sourcedock.dev/petrbalvin/interpres/v2"
)
const sampleConfig = `listen = ":2049"
log-ops = true
state-dir = "/var/lib/nfsd"
max-connections = 64
[tls]
cert = "/etc/nfsd/cert.pem"
key = "/etc/nfsd/key.pem"
[[export]]
path = "/srv/demo"
read-only = true
`
func writeConfig(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "nfsd.toml")
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
return p
}
// TestLoadConfig reads the whole sample back, every table and key.
func TestLoadConfig(t *testing.T) {
cfg, err := loadConfig(writeConfig(t, sampleConfig))
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Listen != ":2049" || !cfg.LogOps || cfg.StateDir != "/var/lib/nfsd" ||
cfg.MaxConnections != 64 {
t.Fatalf("scalars: %+v", cfg)
}
if cfg.TLS == nil || cfg.TLS.Cert != "/etc/nfsd/cert.pem" || cfg.TLS.Key != "/etc/nfsd/key.pem" {
t.Fatalf("tls: %+v", cfg.TLS)
}
if len(cfg.Exports) != 1 || cfg.Exports[0].Path != "/srv/demo" || !cfg.Exports[0].ReadOnly {
t.Fatalf("exports: %+v", cfg.Exports)
}
}
// TestLoadConfigEmptyPath covers the explicitness rule: no -config, no
// file read, zero configuration.
func TestLoadConfigEmptyPath(t *testing.T) {
cfg, err := loadConfig("")
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Listen != "" || len(cfg.Exports) != 0 {
t.Fatalf("an empty path answered %+v", cfg)
}
}
// TestLoadConfigUnknownKey rejects a key the schema does not carry: a
// typo in the file must end the start up, not slip through.
func TestLoadConfigUnknownKey(t *testing.T) {
_, err := loadConfig(writeConfig(t, "lisn = \":2049\"\n"))
if err == nil {
t.Fatal("an unknown key was accepted")
}
var se *interpres.SyntaxError
if !errors.As(err, &se) && !strings.Contains(err.Error(), "lisn") &&
!strings.Contains(err.Error(), "unknown") {
t.Fatalf("the error names neither the key nor its kind: %v", err)
}
}
// TestLoadConfigSyntaxError reports the file and the line of a broken
// document.
func TestLoadConfigSyntaxError(t *testing.T) {
p := writeConfig(t, "listen = \":2049\"\nbroken")
_, err := loadConfig(p)
if err == nil {
t.Fatal("a broken document was accepted")
}
if !strings.Contains(err.Error(), p+":2:") {
t.Fatalf("the error misses the file or the line: %v", err)
}
}
// TestLoadConfigOneExport enforces the exactly one export contract.
func TestLoadConfigOneExport(t *testing.T) {
if _, err := loadConfig(writeConfig(t, "listen = \":2049\"")); err == nil {
t.Fatal("a file without an export was accepted")
}
if _, err := loadConfig(writeConfig(t,
"[[export]]\npath = \"/a\"\n\n[[export]]\npath = \"/b\"\n")); err == nil {
t.Fatal("a file with two exports was accepted")
}
if _, err := loadConfig(writeConfig(t, "[[export]]\nread-only = true\n")); err == nil {
t.Fatal("an export without a path was accepted")
}
}
// newTestFlags builds the flag set of main with the same names and
// defaults.
func newTestFlags() (addr, export *string, ro, logOps *bool, tlsCert, tlsKey, stateDir *string,
maxConns *int, fs *flag.FlagSet) {
fs = flag.NewFlagSet("nfsd-test", flag.ContinueOnError)
addr = fs.String("addr", ":2049", "")
export = fs.String("export", "", "")
ro = fs.Bool("ro", false, "")
tlsCert = fs.String("tls-cert", "", "")
tlsKey = fs.String("tls-key", "", "")
logOps = fs.Bool("log-ops", false, "")
stateDir = fs.String("state-dir", "", "")
maxConns = fs.Int("max-connections", 0, "")
return
}
// TestApplyConfigFillsUnsetFlags: the file is the base.
func TestApplyConfigFillsUnsetFlags(t *testing.T) {
cfg, err := loadConfig(writeConfig(t, sampleConfig))
if err != nil {
t.Fatalf("load: %v", err)
}
addr, export, ro, logOps, tlsCert, tlsKey, stateDir, maxConns, fs := newTestFlags()
applyConfig(cfg, fs, func(string) bool { return false })
if *addr != ":2049" || *export != "/srv/demo" || !*ro || !*logOps ||
*stateDir != "/var/lib/nfsd" || *maxConns != 64 ||
*tlsCert != "/etc/nfsd/cert.pem" || *tlsKey != "/etc/nfsd/key.pem" {
t.Fatalf("flags after the file: addr=%q export=%q ro=%v logOps=%v stateDir=%q maxConns=%d tls=%q,%q",
*addr, *export, *ro, *logOps, *stateDir, *maxConns, *tlsCert, *tlsKey)
}
}
// TestApplyConfigFlagsWin: the flags override the file.
func TestApplyConfigFlagsWin(t *testing.T) {
cfg, err := loadConfig(writeConfig(t, sampleConfig))
if err != nil {
t.Fatalf("load: %v", err)
}
addr, export, ro, logOps, tlsCert, tlsKey, stateDir, maxConns, fs := newTestFlags()
fs.Parse([]string{"-addr", ":9999", "-export", "/other", "-ro=false", "-log-ops=false"})
given := make(map[string]bool)
fs.Visit(func(f *flag.Flag) { given[f.Name] = true })
applyConfig(cfg, fs, func(name string) bool { return given[name] })
if *addr != ":9999" {
t.Fatalf("addr %q, the command line must win", *addr)
}
if *export != "/other" {
t.Fatalf("export %q, the command line must win", *export)
}
if *ro {
t.Fatal("ro must stay false, the command line set it")
}
if *logOps {
t.Fatal("log-ops must stay false, the command line set it")
}
if *stateDir != "/var/lib/nfsd" || *maxConns != 64 || *tlsCert == "" || *tlsKey == "" {
t.Fatalf("the file lost the keys the command line left alone: stateDir=%q maxConns=%d tls=%q,%q",
*stateDir, *maxConns, *tlsCert, *tlsKey)
}
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command nfsd serves NFS over TCP. It is the server binary of the nfs
// project: it exports one local directory tree over NFSv4.2 as described
// in docs/ARCHITECTURE.md.
package main
import (
"context"
"crypto/tls"
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"runtime/debug"
"syscall"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4server"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/server"
)
func main() {
log.SetFlags(0)
flags := flag.CommandLine
addr := flags.String("addr", ":2049", "TCP address to listen on")
export := flags.String("export", "", "directory to serve")
readOnly := flags.Bool("ro", false, "serve the export read only: every mutation answers NFS4ERR_ROFS")
rootSquash := flags.Bool("root-squash", false, "map a client claiming uid 0 onto nobody (65534), so root acts as the anonymous identity")
tlsCert := flags.String("tls-cert", "", "certificate chain in PEM for RPC-with-TLS; requires -tls-key")
tlsKey := flags.String("tls-key", "", "private key in PEM for RPC-with-TLS; requires -tls-cert")
logOps := flags.Bool("log-ops", false, "log every operation with its status and duration to stderr")
maxConns := flags.Int("max-connections", 0, "cap on live connections; a connection above the cap closes at once; 0 means no cap")
stateDir := flags.String("state-dir", "", "directory for persisted client state: handles and opens survive a restart, and a grace window follows it")
configPath := flags.String("config", "", "configuration file in TOML; never read unless named, the flags override it")
version := flags.Bool("version", false, "print the version and exit")
flags.Parse(os.Args[1:])
// The configuration file is the base, the flags override it: only
// the flags present on the command line keep their value, everything
// else yields to the file.
if *configPath != "" {
cfg, err := loadConfig(*configPath)
if err != nil {
log.Fatalf("nfsd: %v", err)
}
given := make(map[string]bool)
flags.Visit(func(f *flag.Flag) { given[f.Name] = true })
applyConfig(cfg, flags, func(name string) bool { return given[name] })
}
if *version {
fmt.Println(buildVersion())
return
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if *export == "" {
log.Fatalf("nfsd: no export given: pass -export DIR")
}
backend, err := nfsfs.NewLocal(*export)
if err != nil {
log.Fatalf("nfsd: %v", err)
}
var fs nfsfs.FS = backend
if *readOnly {
fs = nfsfs.ReadOnly(backend)
}
if *stateDir != "" {
if err := os.MkdirAll(*stateDir, 0o700); err != nil {
log.Fatalf("nfsd: %v", err)
}
// The handle map is the backend half of the recovery state: load
// what a previous life wrote, then keep writing as handles are
// minted, so a restart resolves what it served before.
if err := backend.LoadPersistedHandles(*stateDir); err != nil {
log.Fatalf("nfsd: %v", err)
}
backend.SetPersistPath(*stateDir)
}
var tlsCfg *tls.Config
if *tlsCert != "" || *tlsKey != "" {
if *tlsCert == "" || *tlsKey == "" {
log.Fatalf("nfsd: -tls-cert and -tls-key go together")
}
cert, err := tls.LoadX509KeyPair(*tlsCert, *tlsKey)
if err != nil {
log.Fatalf("nfsd: %v", err)
}
tlsCfg = &tls.Config{Certificates: []tls.Certificate{cert}}
}
ln, err := net.Listen("tcp", *addr)
if err != nil {
log.Fatalf("nfsd: %v", err)
}
log.Printf("nfsd: serving %s on %s", *export, ln.Addr())
// The listener is the moment the service can answer: a Type=notify
// unit learns it here.
notifyReadyOrLog()
srv := &server.Server{
Handle: (&nfs4server.Handler{
FS: fs,
TLSConfig: tlsCfg,
LogOps: *logOps,
StateDir: *stateDir,
RootSquash: *rootSquash,
}).HandleConn,
MaxConns: *maxConns,
}
if err := srv.Serve(ctx, ln); err != nil {
log.Fatalf("nfsd: %v", err)
}
}
// buildVersion reports the module version the toolchain recorded at build
// time. A build made at a tag reports the tag; a build outside version
// control reports devel.
func buildVersion() string {
v := "devel"
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "(devel)" {
v = bi.Main.Version
}
return v
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package main
import (
"errors"
"log"
"net"
"os"
)
// notifyReady announces readiness to the service manager over
// $NOTIFY_SOCKET, the raw unix datagram socket of the sd_notify
// protocol. The message is the one READY=1 line; nothing else is sent
// and no dependency is pulled in. Without the variable, the process is
// not running under a Type=notify unit and the step is skipped quietly.
func notifyReady() error {
addr := os.Getenv("NOTIFY_SOCKET")
if addr == "" {
return nil
}
conn, err := net.DialUnix("unixgram", nil, &net.UnixAddr{Name: addr, Net: "unixgram"})
if err != nil {
return err
}
defer conn.Close()
if _, err := conn.Write([]byte("READY=1")); err != nil {
return err
}
return nil
}
// notifyReadyOrLog announces readiness and reports a failure on the log
// without ending the service: a manager that expects the notification
// times the unit out, but the server itself is ready to serve either way.
func notifyReadyOrLog() {
if err := notifyReady(); err != nil && !errors.Is(err, os.ErrNotExist) {
log.Printf("nfsd: the readiness notification failed: %v", err)
}
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package main
import (
"net"
"path/filepath"
"testing"
"time"
)
// TestNotifyReadyWithoutSocket covers the quiet path: no NOTIFY_SOCKET,
// no notification, no error. This is every run outside a Type=notify
// unit.
func TestNotifyReadyWithoutSocket(t *testing.T) {
t.Setenv("NOTIFY_SOCKET", "")
if err := notifyReady(); err != nil {
t.Fatalf("notify without a socket: %v", err)
}
}
// expectReady reads one datagram and reports whether it is READY=1.
func expectReady(t *testing.T, ln *net.UnixConn) {
t.Helper()
buf := make([]byte, 64)
ln.SetReadDeadline(time.Now().Add(2 * time.Second))
n, _, err := ln.ReadFrom(buf)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(buf[:n]) != "READY=1" {
t.Fatalf("message %q, want READY=1", buf[:n])
}
}
// TestNotifyReadyDelivers covers the pathname socket form.
func TestNotifyReadyDelivers(t *testing.T) {
sock := filepath.Join(t.TempDir(), "notify.sock")
ln, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: sock, Net: "unixgram"})
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
t.Setenv("NOTIFY_SOCKET", sock)
if err := notifyReady(); err != nil {
t.Fatalf("notify: %v", err)
}
expectReady(t, ln)
}
// TestNotifyReadyAbstract covers the abstract namespace form, the one
// systemd hands over on Linux.
func TestNotifyReadyAbstract(t *testing.T) {
addr := "@" + filepath.Base(t.TempDir()) + "-notify"
ln, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: addr, Net: "unixgram"})
if err != nil {
t.Skipf("abstract unix sockets are unavailable: %v", err)
}
defer ln.Close()
t.Setenv("NOTIFY_SOCKET", addr)
if err := notifyReady(); err != nil {
t.Fatalf("notify: %v", err)
}
expectReady(t, ln)
}