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
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:
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
|
||||
)
|
||||
|
||||
// putChunk is the size of one in flight read or write of get and put,
|
||||
// the same megabyte the commands have always transferred per compound.
|
||||
const putChunk = 1 << 20
|
||||
|
||||
// cmdGet mirrors a remote file into a local file through READ compounds,
|
||||
// up to workers of them in flight. The local file is created first, so a
|
||||
// shorter remote leaves no tail behind.
|
||||
func cmdGet(cl *nfsclient.Client, remote, local string, workers int) error {
|
||||
out, err := os.Create(local)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
var next atomic.Int64
|
||||
var stop atomic.Bool
|
||||
type chunk struct {
|
||||
idx int64
|
||||
data []byte
|
||||
eof bool
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
pages := make(chan chunk, workers)
|
||||
var wg sync.WaitGroup
|
||||
for range workers {
|
||||
wg.Go(func() {
|
||||
for {
|
||||
if stop.Load() {
|
||||
return
|
||||
}
|
||||
idx := next.Add(1) - 1
|
||||
ops := append(pathOps(remote),
|
||||
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, uint64(idx)*putChunk, putChunk))
|
||||
res, bodies, err := cl.Compound("get", ops)
|
||||
if err != nil {
|
||||
done <- err
|
||||
stop.Store(true)
|
||||
return
|
||||
}
|
||||
if res.Status != nfs4.ErrOK {
|
||||
done <- fmt.Errorf("get: nfs status %d", res.Status)
|
||||
stop.Store(true)
|
||||
return
|
||||
}
|
||||
body, err := bodyAt("get", bodies, len(bodies)-1)
|
||||
if err != nil {
|
||||
done <- err
|
||||
stop.Store(true)
|
||||
return
|
||||
}
|
||||
d := xdr.NewDecoder(body)
|
||||
eof, err := d.Bool()
|
||||
if err != nil {
|
||||
done <- err
|
||||
stop.Store(true)
|
||||
return
|
||||
}
|
||||
data, err := d.VarOpaque()
|
||||
if err != nil {
|
||||
done <- err
|
||||
stop.Store(true)
|
||||
return
|
||||
}
|
||||
pages <- chunk{idx: idx, data: data, eof: eof}
|
||||
if eof {
|
||||
stop.Store(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
go func() { wg.Wait(); close(pages) }()
|
||||
|
||||
var total uint64
|
||||
for page := range pages {
|
||||
if _, err := out.WriteAt(page.data, page.idx*putChunk); err != nil {
|
||||
return err
|
||||
}
|
||||
total += uint64(len(page.data))
|
||||
if page.eof {
|
||||
stop.Store(true)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
default:
|
||||
}
|
||||
fmt.Printf("wrote %d bytes from %s\n", total, remote)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cmdPut writes a local file to the server through OPEN and WRITE,
|
||||
// up to workers of them in flight after the truncate.
|
||||
func cmdPut(cl *nfsclient.Client, local, remote string, workers int) error {
|
||||
in, err := os.Open(local)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
info, err := in.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parts := splitPath(remote)
|
||||
if len(parts) == 0 {
|
||||
return fmt.Errorf("put: empty remote path")
|
||||
}
|
||||
name := parts[len(parts)-1]
|
||||
dirOps := pathOps(strings.Join(parts[:len(parts)-1], "/"))
|
||||
openOps := append(dirOps,
|
||||
nfs4.AppendOpenArgs(nil, 0, []byte("nfs-cli"), nfs4.ShareAccessBoth, 0,
|
||||
true, 0o644, name),
|
||||
nfs4.AppendGetfh(nil))
|
||||
res, bodies, err := cl.Compound("put-open", openOps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Status != nfs4.ErrOK {
|
||||
return fmt.Errorf("put: open status %d", res.Status)
|
||||
}
|
||||
var st nfs4.Stateid
|
||||
stateBody, err := bodyAt("put", bodies, len(bodies)-2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
copy(st[:], stateBody)
|
||||
fhBody, err := bodyAt("put", bodies, len(bodies)-1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fh, err := xdr.NewDecoder(fhBody).VarOpaque()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// PUT replaces the whole file: the size is zeroed through SETATTR
|
||||
// before the first write, so a shorter file leaves no tail behind.
|
||||
tres, _, err := cl.Compound("put-truncate", [][]byte{
|
||||
nfs4.AppendPutfh(nil, fh),
|
||||
nfs4.AppendSetattrArgs(nil, nfs4.AllZero, nfs4.OfBits(nfs4.AttrSize), nfs4.Attrs{}),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tres.Status != nfs4.ErrOK {
|
||||
return fmt.Errorf("put: truncate status %d", tres.Status)
|
||||
}
|
||||
|
||||
chunks := (info.Size() + putChunk - 1) / putChunk
|
||||
var next atomic.Int64
|
||||
var stop atomic.Bool
|
||||
done := make(chan error, 1)
|
||||
var wg sync.WaitGroup
|
||||
for range workers {
|
||||
wg.Go(func() {
|
||||
buf := make([]byte, putChunk)
|
||||
for {
|
||||
if stop.Load() {
|
||||
return
|
||||
}
|
||||
idx := next.Add(1) - 1
|
||||
if idx >= chunks {
|
||||
return
|
||||
}
|
||||
n, rerr := in.ReadAt(buf, idx*putChunk)
|
||||
if rerr != nil && !errors.Is(rerr, os.ErrClosed) {
|
||||
// A short final read is the file's end, not a failure.
|
||||
if !errors.Is(rerr, io.EOF) {
|
||||
done <- rerr
|
||||
stop.Store(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
wres, _, err := cl.Compound("put-write", [][]byte{
|
||||
nfs4.AppendPutfh(nil, fh),
|
||||
nfs4.AppendWriteArgs(nil, st, uint64(idx)*putChunk, nfs4.StableFileSync, buf[:n]),
|
||||
})
|
||||
if err != nil {
|
||||
done <- err
|
||||
stop.Store(true)
|
||||
return
|
||||
}
|
||||
if wres.Status != nfs4.ErrOK {
|
||||
done <- fmt.Errorf("put: write status %d", wres.Status)
|
||||
stop.Store(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
default:
|
||||
}
|
||||
cres, _, err := cl.Compound("put-close", [][]byte{
|
||||
nfs4.AppendPutfh(nil, fh),
|
||||
nfs4.AppendCloseArgs(nil, st),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cres.Status != nfs4.ErrOK {
|
||||
return fmt.Errorf("put: close status %d", cres.Status)
|
||||
}
|
||||
fmt.Printf("wrote %d bytes to %s\n", info.Size(), remote)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cmdRm removes one object from the server through REMOVE.
|
||||
func cmdRm(cl *nfsclient.Client, path string) error {
|
||||
parts := splitPath(path)
|
||||
if len(parts) == 0 {
|
||||
return errors.New("rm: empty path")
|
||||
}
|
||||
dir := strings.Join(parts[:len(parts)-1], "/")
|
||||
ops := append(pathOps(dir), nfs4.AppendRemoveArgs(nil, parts[len(parts)-1]))
|
||||
res, _, err := cl.Compound("rm", ops)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Status != nfs4.ErrOK {
|
||||
return fmt.Errorf("rm: nfs status %d", res.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cmdMkdir makes one directory on the server through CREATE NF4DIR.
|
||||
func cmdMkdir(cl *nfsclient.Client, path string) error {
|
||||
parts := splitPath(path)
|
||||
if len(parts) == 0 {
|
||||
return errors.New("mkdir: empty path")
|
||||
}
|
||||
dir := strings.Join(parts[:len(parts)-1], "/")
|
||||
ops := append(pathOps(dir),
|
||||
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, parts[len(parts)-1], "", 0, 0, 0o755))
|
||||
res, _, err := cl.Compound("mkdir", ops)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Status != nfs4.ErrOK {
|
||||
return fmt.Errorf("mkdir: nfs status %d", res.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command nfs is the client binary of the nfs project: a protocol client
|
||||
// over TCP with a small set of operations, built on the nfsclient
|
||||
// library that carries the full NFSv4.2 surface.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "127.0.0.1:2049", "server address")
|
||||
concurrency := flag.Int("concurrency", 1, "compounds in flight for get and put, 1 to 8; 1 keeps the transfers sequential")
|
||||
flag.Parse()
|
||||
args := flag.Args()
|
||||
if len(args) == 0 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
var err error
|
||||
switch args[0] {
|
||||
case "version":
|
||||
fmt.Println(buildVersion())
|
||||
return
|
||||
case "ls":
|
||||
err = run(*addr, 1, func(cl *nfsclient.Client) error { return cmdLs(cl, pathArg(args, 1)) })
|
||||
case "cat":
|
||||
if len(args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
err = run(*addr, 1, func(cl *nfsclient.Client) error { return cmdCat(cl, args[1]) })
|
||||
case "put":
|
||||
if len(args) < 3 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
err = run(*addr, *concurrency, func(cl *nfsclient.Client) error { return cmdPut(cl, args[1], args[2], *concurrency) })
|
||||
case "get":
|
||||
if len(args) < 3 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
err = run(*addr, *concurrency, func(cl *nfsclient.Client) error { return cmdGet(cl, args[1], args[2], *concurrency) })
|
||||
case "rm":
|
||||
if len(args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
err = run(*addr, 1, func(cl *nfsclient.Client) error { return cmdRm(cl, args[1]) })
|
||||
case "mkdir":
|
||||
if len(args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
err = run(*addr, 1, func(cl *nfsclient.Client) error { return cmdMkdir(cl, args[1]) })
|
||||
case "stat":
|
||||
if len(args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
err = run(*addr, 1, func(cl *nfsclient.Client) error { return cmdStat(cl, args[1]) })
|
||||
case "selftest":
|
||||
err = run(*addr, 1, cmdSelftest)
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "nfs: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage: nfs [-addr host:port] version | ls [path] | cat path | put local remote | get remote local | rm path | mkdir path | stat path | selftest")
|
||||
}
|
||||
|
||||
// run dials the server, establishes a session and hands the connection
|
||||
// to one command.
|
||||
func run(addr string, concurrency int, cmd func(*nfsclient.Client) error) error {
|
||||
cl, err := nfsclient.Dial(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cl.Close()
|
||||
// The owner id is the client identity on the server: two processes
|
||||
// sharing one owner look like the same client rebooting, so every
|
||||
// run names itself with its process and a fresh stamp.
|
||||
owner := fmt.Sprintf("nfs-cli-%d-%d", os.Getpid(), time.Now().UnixNano())
|
||||
if err := cl.Establish(owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if concurrency != 1 {
|
||||
if err := cl.SetConcurrency(concurrency); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return cmd(cl)
|
||||
}
|
||||
|
||||
// pathOps builds the operation prefix that walks from the root to a
|
||||
// path; the empty path addresses the root itself.
|
||||
func pathOps(path string) [][]byte {
|
||||
ops := [][]byte{nfs4.AppendPutRootfh(nil)}
|
||||
for _, part := range splitPath(path) {
|
||||
ops = append(ops, nfs4.AppendLookup(nil, part))
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
func splitPath(path string) []string {
|
||||
return strings.FieldsFunc(strings.Trim(path, "/"), func(r rune) bool { return r == '/' })
|
||||
}
|
||||
|
||||
func pathArg(args []string, i int) string {
|
||||
if len(args) > i {
|
||||
return args[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// bodyAt answers one result body of a compound the client ran, guarding
|
||||
// the shape a hostile or broken server answered before anything indexes
|
||||
// it.
|
||||
func bodyAt(cmd string, bodies [][]byte, i int) ([]byte, error) {
|
||||
if i < 0 || i >= len(bodies) {
|
||||
return nil, fmt.Errorf("%s: the server answered %d results", cmd, len(bodies))
|
||||
}
|
||||
return bodies[i], nil
|
||||
}
|
||||
|
||||
// cmdLs lists one directory.
|
||||
func cmdLs(cl *nfsclient.Client, path string) error {
|
||||
ops := append(pathOps(path), nfs4.AppendReaddir(nil, 0, [8]byte{}, 1<<20, 1<<20,
|
||||
nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize, nfs4.AttrMode)))
|
||||
res, bodies, err := cl.Compound("ls", ops)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Status != nfs4.ErrOK {
|
||||
return fmt.Errorf("ls: nfs status %d", res.Status)
|
||||
}
|
||||
body, err := bodyAt("ls", bodies, len(bodies)-1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, entries, _, err := nfs4.DecodeReadDirBody(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
fmt.Printf("%s\t%d\t%o\n", e.Name, e.Attrs.Size, e.Attrs.Mode&0o7777)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cmdCat streams a file to stdout.
|
||||
func cmdCat(cl *nfsclient.Client, path string) error {
|
||||
var offset uint64
|
||||
for {
|
||||
ops := append(pathOps(path),
|
||||
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, offset, 1<<20))
|
||||
res, bodies, err := cl.Compound("cat", ops)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Status != nfs4.ErrOK {
|
||||
return fmt.Errorf("cat: nfs status %d", res.Status)
|
||||
}
|
||||
body, err := bodyAt("cat", bodies, len(bodies)-1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d := xdr.NewDecoder(body)
|
||||
eof, err := d.Bool()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := d.VarOpaque()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stdout.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
offset += uint64(len(data))
|
||||
if eof {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cmdStat prints the attributes of one object.
|
||||
func cmdStat(cl *nfsclient.Client, path string) error {
|
||||
request := nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize, nfs4.AttrMode,
|
||||
nfs4.AttrTimeModify)
|
||||
ops := append(pathOps(path), nfs4.AppendGetattr(nil, request))
|
||||
res, bodies, err := cl.Compound("stat", ops)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Status != nfs4.ErrOK {
|
||||
return fmt.Errorf("stat: nfs status %d", res.Status)
|
||||
}
|
||||
body, err := bodyAt("stat", bodies, len(bodies)-1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d := xdr.NewDecoder(body)
|
||||
if _, err := nfs4.ReadBitmap(d); err != nil {
|
||||
return err
|
||||
}
|
||||
blob, err := d.VarOpaque()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attrs, err := nfs4.DecodeFattrAttrs(blob, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("type %d\nsize %d\nmode %o\nmtime %d.%09d\n",
|
||||
attrs.Type, attrs.Size, attrs.Mode&0o7777,
|
||||
attrs.TimeModify.Seconds, attrs.TimeModify.Nseconds)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildVersion reports the module version the toolchain recorded at
|
||||
// build time, falling back to a development label.
|
||||
func buildVersion() string {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
return info.Main.Version
|
||||
}
|
||||
return "(devel)"
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
|
||||
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
|
||||
)
|
||||
|
||||
// selftestChunk is the size of the write and compare payload of the
|
||||
// battery: the same 64 KiB the reference benchmarks use.
|
||||
const selftestChunk = 64 << 10
|
||||
|
||||
// a selftestCheck is one named test of the battery.
|
||||
type selftestCheck struct {
|
||||
name string
|
||||
run func(*nfsclient.Client, string) error
|
||||
}
|
||||
|
||||
// cmdSelftest runs the whole operation matrix against the running server
|
||||
// the session points at, prints one line per check and a summary, and
|
||||
// fails when any check fails. The work directory is removed on success
|
||||
// and left behind on failure, so a failing server can be examined.
|
||||
func cmdSelftest(cl *nfsclient.Client) error {
|
||||
dir := fmt.Sprintf("selftest-%d", time.Now().UnixNano())
|
||||
checks := []selftestCheck{
|
||||
{"mkdir work directory", selfMkdir},
|
||||
{"touch empty file", selfTouch},
|
||||
{"write 64 KiB", selfWrite},
|
||||
{"read back and compare 64 KiB", selfCompare},
|
||||
{"list directory", selfList},
|
||||
{"rename", selfRename},
|
||||
{"symlink and readlink", selfSymlink},
|
||||
{"nested directory", selfNested},
|
||||
{"ownership of new files", selfOwnership},
|
||||
{"setattr mode", selfSetattr},
|
||||
{"remove files", selfRemove},
|
||||
{"remove work directory", selfRemoveWorkdir},
|
||||
}
|
||||
var failed int
|
||||
for _, check := range checks {
|
||||
err := check.run(cl, dir)
|
||||
if err != nil {
|
||||
failed++
|
||||
fmt.Printf("FAIL %s: %v\n", check.name, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("PASS %s\n", check.name)
|
||||
}
|
||||
fmt.Printf("%d/%d checks passed against %s\n", len(checks)-failed, len(checks), dir)
|
||||
if failed > 0 {
|
||||
return errors.New("selftest: the battery failed; the work directory is left in place")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// selfCompound runs one COMPOUND and requires success of it.
|
||||
func selfCompound(cl *nfsclient.Client, tag string, ops [][]byte) (nfs4.CompoundRes, [][]byte, error) {
|
||||
res, bodies, err := cl.Compound(tag, ops)
|
||||
if err != nil {
|
||||
return res, bodies, err
|
||||
}
|
||||
if res.Status != nfs4.ErrOK {
|
||||
return res, bodies, fmt.Errorf("%s: nfs status %d", tag, res.Status)
|
||||
}
|
||||
return res, bodies, nil
|
||||
}
|
||||
|
||||
// selfAttrs reports the attributes of one path.
|
||||
func selfAttrs(cl *nfsclient.Client, path string) (nfs4.Attrs, error) {
|
||||
request := nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize, nfs4.AttrMode,
|
||||
nfs4.AttrNumlinks, nfs4.AttrOwner)
|
||||
_, bodies, err := selfCompound(cl, "selftest-getattr",
|
||||
append(pathOps(path), nfs4.AppendGetattr(nil, request)))
|
||||
if err != nil {
|
||||
return nfs4.Attrs{}, err
|
||||
}
|
||||
return decodeAttrs(bodies[len(bodies)-1], request)
|
||||
}
|
||||
|
||||
// decodeAttrs decodes one GETATTR result body.
|
||||
func decodeAttrs(body []byte, request nfs4.Bitmap) (nfs4.Attrs, error) {
|
||||
d := xdr.NewDecoder(body)
|
||||
if _, err := nfs4.ReadBitmap(d); err != nil {
|
||||
return nfs4.Attrs{}, err
|
||||
}
|
||||
blob, err := d.VarOpaque()
|
||||
if err != nil {
|
||||
return nfs4.Attrs{}, err
|
||||
}
|
||||
return nfs4.DecodeFattrAttrs(blob, request)
|
||||
}
|
||||
|
||||
// selfOpenNew opens a new file for writing, the way put does, and
|
||||
// returns the handle and the stateid.
|
||||
func selfOpenNew(cl *nfsclient.Client, path string, perm uint32) ([]byte, nfs4.Stateid, error) {
|
||||
parts := splitPath(path)
|
||||
dir, name := strings.Join(parts[:len(parts)-1], "/"), parts[len(parts)-1]
|
||||
_, bodies, err := selfCompound(cl, "selftest-open", append(pathOps(dir),
|
||||
nfs4.AppendOpenArgs(nil, 0, []byte("selftest"), nfs4.ShareAccessBoth, 0,
|
||||
true, perm, name),
|
||||
nfs4.AppendGetfh(nil)))
|
||||
if err != nil {
|
||||
return nil, nfs4.Stateid{}, err
|
||||
}
|
||||
fhBody, err := bodyAt("selftest", bodies, len(bodies)-1)
|
||||
if err != nil {
|
||||
return nil, nfs4.Stateid{}, err
|
||||
}
|
||||
fh, err := xdr.NewDecoder(fhBody).VarOpaque()
|
||||
if err != nil {
|
||||
return nil, nfs4.Stateid{}, err
|
||||
}
|
||||
var st nfs4.Stateid
|
||||
stateBody, err := bodyAt("selftest", bodies, len(bodies)-2)
|
||||
if err != nil {
|
||||
return nil, nfs4.Stateid{}, err
|
||||
}
|
||||
if len(stateBody) < len(st) {
|
||||
return nil, nfs4.Stateid{}, fmt.Errorf("open result carries %d bytes of stateid", len(stateBody))
|
||||
}
|
||||
copy(st[:], stateBody)
|
||||
return fh, st, nil
|
||||
}
|
||||
|
||||
// selfClose closes one open file.
|
||||
func selfClose(cl *nfsclient.Client, fh []byte, st nfs4.Stateid) error {
|
||||
_, _, err := selfCompound(cl, "selftest-close", [][]byte{
|
||||
nfs4.AppendPutfh(nil, fh),
|
||||
nfs4.AppendCloseArgs(nil, st),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// selfMkdir makes the work directory and confirms it is a directory.
|
||||
func selfMkdir(cl *nfsclient.Client, dir string) error {
|
||||
parts := splitPath(dir)
|
||||
if _, _, err := selfCompound(cl, "selftest-mkdir", append(pathOps(""),
|
||||
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, parts[0], "", 0, 0, 0o755))); err != nil {
|
||||
return err
|
||||
}
|
||||
attrs, err := selfAttrs(cl, dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if attrs.Type != nfs4.NF4Dir {
|
||||
return fmt.Errorf("type %d, want a directory", attrs.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// selfTouch creates an empty file through OPEN and confirms its size.
|
||||
func selfTouch(cl *nfsclient.Client, dir string) error {
|
||||
fh, st, err := selfOpenNew(cl, dir+"/empty", 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := selfClose(cl, fh, st); err != nil {
|
||||
return err
|
||||
}
|
||||
attrs, err := selfAttrs(cl, dir+"/empty")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if attrs.Size != 0 {
|
||||
return fmt.Errorf("size %d, want 0", attrs.Size)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// selfPattern returns the deterministic payload chunk the battery writes
|
||||
// and compares.
|
||||
func selfPattern() []byte {
|
||||
buf := make([]byte, selftestChunk)
|
||||
for i := range buf {
|
||||
buf[i] = byte(i * 7)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
// selfWrite opens a fresh file and writes one 64 KiB pattern through it.
|
||||
func selfWrite(cl *nfsclient.Client, dir string) error {
|
||||
fh, st, err := selfOpenNew(cl, dir+"/data", 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err = selfCompound(cl, "selftest-write", [][]byte{
|
||||
nfs4.AppendPutfh(nil, fh),
|
||||
nfs4.AppendWriteArgs(nil, st, 0, nfs4.StableFileSync, selfPattern()),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return selfClose(cl, fh, st)
|
||||
}
|
||||
|
||||
// selfCompare reads the written file back in one READ loop and compares
|
||||
// it byte for byte with the pattern.
|
||||
func selfCompare(cl *nfsclient.Client, dir string) error {
|
||||
got, err := readWhole(cl, dir+"/data")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !bytes.Equal(got, selfPattern()) {
|
||||
return fmt.Errorf("%d bytes read back, content differs", len(got))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readWhole streams one file to memory, the way cat does.
|
||||
func readWhole(cl *nfsclient.Client, path string) ([]byte, error) {
|
||||
var out []byte
|
||||
var offset uint64
|
||||
for {
|
||||
_, bodies, err := selfCompound(cl, "selftest-read", append(pathOps(path),
|
||||
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, offset, selftestChunk)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := xdr.NewDecoder(bodies[len(bodies)-1])
|
||||
eof, err := d.Bool()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := d.VarOpaque()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, data...)
|
||||
offset += uint64(len(data))
|
||||
if eof {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// selfList lists the work directory and expects the two files of the
|
||||
// earlier checks.
|
||||
func selfList(cl *nfsclient.Client, dir string) error {
|
||||
_, bodies, err := selfCompound(cl, "selftest-readdir", append(pathOps(dir),
|
||||
nfs4.AppendReaddir(nil, 0, [8]byte{}, 1<<20, 1<<20,
|
||||
nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize, nfs4.AttrMode))))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, entries, _, err := nfs4.DecodeReadDirBody(bodies[len(bodies)-1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := make(map[string]bool, len(entries))
|
||||
for _, e := range entries {
|
||||
names[e.Name] = true
|
||||
}
|
||||
for _, want := range []string{"empty", "data"} {
|
||||
if !names[want] {
|
||||
return fmt.Errorf("entry %s missing from the listing", want)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// selfRename moves data to data2 within the work directory and confirms
|
||||
// the old name is gone.
|
||||
func selfRename(cl *nfsclient.Client, dir string) error {
|
||||
if _, _, err := selfCompound(cl, "selftest-rename", append(pathOps(dir),
|
||||
nfs4.AppendSavefh(nil),
|
||||
nfs4.AppendRenameArgs(nil, "data", "data2"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := selfAttrs(cl, dir+"/data"); err == nil {
|
||||
return errors.New("the old name still resolves")
|
||||
}
|
||||
attrs, err := selfAttrs(cl, dir+"/data2")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if attrs.Size != selftestChunk {
|
||||
return fmt.Errorf("size %d after the move, want %d", attrs.Size, selftestChunk)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// selfSymlink creates a symlink and reads its target back.
|
||||
func selfSymlink(cl *nfsclient.Client, dir string) error {
|
||||
if _, _, err := selfCompound(cl, "selftest-symlink", append(pathOps(dir),
|
||||
nfs4.AppendCreateArgs(nil, nfs4.NF4Lnk, "link", "data2", 0, 0, 0))); err != nil {
|
||||
return err
|
||||
}
|
||||
_, bodies, err := selfCompound(cl, "selftest-readlink", append(pathOps(dir+"/link"),
|
||||
nfs4.AppendReadlinkArgs(nil)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := xdr.NewDecoder(bodies[len(bodies)-1]).String()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target != "data2" {
|
||||
return fmt.Errorf("target %q, want data2", target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// selfNested makes a directory inside the work directory, a file inside
|
||||
// it, lists both and removes the inner file again.
|
||||
func selfNested(cl *nfsclient.Client, dir string) error {
|
||||
if _, _, err := selfCompound(cl, "selftest-mkdir-nested", append(pathOps(dir),
|
||||
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, "inner", "", 0, 0, 0o755))); err != nil {
|
||||
return err
|
||||
}
|
||||
fh, st, err := selfOpenNew(cl, dir+"/inner/file", 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := selfClose(cl, fh, st); err != nil {
|
||||
return err
|
||||
}
|
||||
_, bodies, err := selfCompound(cl, "selftest-readdir-nested", append(pathOps(dir+"/inner"),
|
||||
nfs4.AppendReaddir(nil, 0, [8]byte{}, 1<<20, 1<<20,
|
||||
nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize, nfs4.AttrMode))))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, entries, _, err := nfs4.DecodeReadDirBody(bodies[len(bodies)-1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name != "file" {
|
||||
return fmt.Errorf("%d entries in the nested listing, want exactly file", len(entries))
|
||||
}
|
||||
if _, _, err := selfCompound(cl, "selftest-remove-nested", append(pathOps(dir+"/inner"),
|
||||
nfs4.AppendRemoveArgs(nil, "file"))); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// selfOwnership creates a file as uid 1234 in a world writable scratch
|
||||
// directory and confirms the object carries that owner. A server that
|
||||
// runs as root hands the identity over; a server without the privilege
|
||||
// keeps its own, which is the documented fallback of the service model,
|
||||
// so the owner of the scratch directory names the accepted alternative.
|
||||
func selfOwnership(cl *nfsclient.Client, dir string) error {
|
||||
scratch := dir + "/owned"
|
||||
if _, _, err := selfCompound(cl, "selftest-owned-dir", append(pathOps(dir),
|
||||
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, "owned", "", 0, 0, 0o755))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := selfCompound(cl, "selftest-owned-mode", append(pathOps(scratch),
|
||||
nfs4.AppendSetattrArgs(nil, nfs4.Stateid{}, nfs4.OfBits(nfs4.AttrMode),
|
||||
nfs4.Attrs{Mode: 0o777}))); err != nil {
|
||||
return err
|
||||
}
|
||||
workdir, err := selfAttrs(cl, dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cl.SetIdentity(1234, 1234, []uint32{1234})
|
||||
fh, st, err := selfOpenNew(cl, scratch+"/file", 0o666)
|
||||
if err != nil {
|
||||
cl.SetIdentity(0, 0, nil)
|
||||
return err
|
||||
}
|
||||
if err := selfClose(cl, fh, st); err != nil {
|
||||
cl.SetIdentity(0, 0, nil)
|
||||
return err
|
||||
}
|
||||
cl.SetIdentity(0, 0, nil)
|
||||
attrs, err := selfAttrs(cl, scratch+"/file")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if attrs.Owner != "1234" && attrs.Owner != workdir.Owner {
|
||||
return fmt.Errorf("owner %q, want 1234 or the service identity %q",
|
||||
attrs.Owner, workdir.Owner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// selfSetattr changes the mode of a file and confirms the change. The
|
||||
// SETATTR walks to the file fresh and carries the zero stateid.
|
||||
func selfSetattr(cl *nfsclient.Client, dir string) error {
|
||||
_, _, err := selfCompound(cl, "selftest-setattr", append(pathOps(dir+"/empty"),
|
||||
nfs4.AppendSetattrArgs(nil, nfs4.Stateid{}, nfs4.OfBits(nfs4.AttrMode),
|
||||
nfs4.Attrs{Mode: 0o600})))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
after, err := selfAttrs(cl, dir+"/empty")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if after.Mode&0o777 != 0o600 {
|
||||
return fmt.Errorf("mode %o after the change, want 600", after.Mode&0o777)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// selfRemove takes the files and the nested directory out of the work
|
||||
// directory and confirms the listing is empty.
|
||||
func selfRemove(cl *nfsclient.Client, dir string) error {
|
||||
for _, name := range []string{"empty", "data2", "link"} {
|
||||
if _, _, err := selfCompound(cl, "selftest-remove", append(pathOps(dir),
|
||||
nfs4.AppendRemoveArgs(nil, name))); err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := selfCompound(cl, "selftest-remove-owned", append(pathOps(dir+"/owned"),
|
||||
nfs4.AppendRemoveArgs(nil, "file"))); err != nil {
|
||||
return fmt.Errorf("owned/file: %w", err)
|
||||
}
|
||||
if _, _, err := selfCompound(cl, "selftest-rmdir-owned", append(pathOps(dir),
|
||||
nfs4.AppendRemoveArgs(nil, "owned"))); err != nil {
|
||||
return fmt.Errorf("owned: %w", err)
|
||||
}
|
||||
if _, _, err := selfCompound(cl, "selftest-rmdir-nested", append(pathOps(dir),
|
||||
nfs4.AppendRemoveArgs(nil, "inner"))); err != nil {
|
||||
return err
|
||||
}
|
||||
_, bodies, err := selfCompound(cl, "selftest-readdir-final", append(pathOps(dir),
|
||||
nfs4.AppendReaddir(nil, 0, [8]byte{}, 1<<20, 1<<20,
|
||||
nfs4.OfBits(nfs4.AttrType))))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, entries, _, err := nfs4.DecodeReadDirBody(bodies[len(bodies)-1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
return fmt.Errorf("%d entries left in the work directory", len(entries))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// selfRemoveWorkdir removes the work directory itself and confirms it is
|
||||
// gone.
|
||||
func selfRemoveWorkdir(cl *nfsclient.Client, dir string) error {
|
||||
if _, _, err := selfCompound(cl, "selftest-rmdir", append(pathOps(""),
|
||||
nfs4.AppendRemoveArgs(nil, dir))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := selfAttrs(cl, dir); err == nil {
|
||||
return errors.New("the work directory still resolves")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user