CLIENT DOCUMENTATION
nfsdiag v0.22.0 — the client namespace
and project reference. Written in C99 against libtirpc.
MIT license.
This page covers nfsdiag client — testing an NFS server
from a client machine — plus the project-wide sections: output
formats, fixtures, packaging, security, architecture, changelog.
Diagnostics that run on the NFS server itself are documented in the
server documentation.
CONTENTS
# CLI reference
nfsdiag client [OPTIONS] <server-ip-or-hostname>
nfsdiag server [OPTIONS]
nfsdiag diff <before.json> <after.json>
Deprecated: nfsdiag [OPTIONS] <host>
(without a subcommand) still runs the client diagnostics as an alias
for nfsdiag client, printing a warning on
stderr. The alias will be removed in 1.0.
nfsdiag client
Diagnostic options
| flag | arg | default | description |
|---|---|---|---|
| -e, --export | PATH | — | Test only this export path instead of all discovered exports. Repeatable up to 64 times to test a chosen subset. |
| -o, --mount-options | OPTS | — | Extra mount options passed verbatim to mount(8) (comma-separated, e.g. soft,timeo=30). |
| --no-mount | — | — | Run only network and RPC checks. Skip all mount operations and filesystem tests. Does not require root. |
| --dry-run | — | — | Print what would be done. Skips mounts and all filesystem diagnostics. Safe for quick pre-flight checks. |
| --read-only | — | — | Do not create or write any test files on the export. Leaves no .nfsdiag-* artefacts. |
| --uid | UID | — | Simulate access as this UID (repeatable for multiple identities). Requires root. Pair with --gid. |
| --gid | GID | — | GID paired with the preceding --uid. If given without --uid, applies to the current effective UID. |
| --groups | G1,G2 | — | Comma-separated supplemental GIDs for UID/GID simulation. Max 64 groups. Requires root. |
| --krb5 | — | — | Check Kerberos prerequisites: valid ticket via klist, running rpc.gssd, Kerberos configuration file. |
| --udp | — | — | Also probe RPC NULLPROC over UDP in addition to the default TCP probes. |
| --ipv4-only | — | — | Force IPv4 for direct TCP port checks. Does not affect mount commands. |
| --ipv6-only | — | — | Force IPv6 for direct TCP port checks. IPv6 literals in mount sources are automatically wrapped in brackets. |
| --no-nfs4-discovery | — | — | Disable NFSv4 pseudo-root fallback. By default, if mountd is unavailable, nfsdiag tries to discover exports via the NFSv4 root. |
| --mount-namespace | — | — | Run mounts inside a private mount namespace so they are invisible to other processes. Root runs attempt this automatically unless disabled. |
| --no-mount-namespace | — | — | Disable automatic private mount namespace use for live mount diagnostics. |
| --dangerous-fs-tests | — | — | Enable symlink, hardlink, FIFO, and device-node probes. These are intentionally opt-in for production exports. --deep is an alias. |
| --allow-risky-mount-options | — | — | Permit risky mount options such as exec, suid, and dev, and skip the default nosuid,nodev,noexec mount hardening. |
| --profile | NAME | — | Apply a preset: quick, safe, full, performance, security, or readonly. |
| --hosts-file | FILE | — | Read one host per line from FILE and run diagnostics for each. Lines starting with # are comments. Respects --delay-ms between hosts. |
| --peer | HOST[:PORT] | 9100 | Correlate with a peer nfsdiag server --listen exporter. The client samples the server's metrics before and after its run and prints a paired: verdict locating the bottleneck (server, network, or client). |
| --watch | SEC | — | Re-run diagnostics every SEC seconds until interrupted with Ctrl-C. Clears terminal between iterations. All mounts are cleaned up on SIGINT. |
| --on-fail-exec | SCRIPT | — | Execute SCRIPT through a resolved trusted path with a minimal environment (never sh -c) when any test fails. Receives NFSDIAG_HOST, NFSDIAG_LEVEL, NFSDIAG_FAIL_COUNT, NFSDIAG_WARN_COUNT. |
| --config | FILE | — | Load options from FILE (key=value, one per line) before parsing remaining CLI arguments. CLI flags override the config file. |
| --parallel | N | 1 | Test up to N exports concurrently (1-32). Each worker mounts, diagnoses, and unmounts its export in a forked child; results are merged into the normal reports. |
| --sweep | — | — | After the main tests, re-mount the first working export with several rsize/wsize/nconnect combinations, benchmark each, and recommend the best-performing mount options. Also enabled by --profile performance. |
| --diff-baseline | — | — | Compare this run's summary with the last saved run for the host (stored under $XDG_DATA_HOME/nfsdiag or ~/.local/share/nfsdiag), report regressions, then update the baseline. |
Timeout options
| flag | arg | default | description |
|---|---|---|---|
| --timeout | SEC | 5 | Network and RPC connect timeout in seconds. Applied to TCP reachability checks and clnt_create calls. |
| --command-timeout | SEC | 30 | Timeout for mount and umount command invocations. Range: 1–3600. |
| --fs-timeout | SEC | 30 | Timeout for each filesystem test group (write/read benchmark, advisory lock, root_squash). Each group runs with its own independent SIGALRM. |
| --delay-ms | MS | 0 | Milliseconds to sleep between testing each export. Useful as a rate limiter when auditing servers under production load. Max: 600000. |
Benchmark options
| flag | arg | default | description |
|---|---|---|---|
| --bench-bytes | BYTES | 4194304 | Size of the read/write benchmark payload in bytes (4 MiB by default). Range 1 byte to 1 GiB; 0 is rejected — use --bench-iterations 0 to skip the benchmark. |
| --bench-iterations | N | 10 | Number of create/rename/unlink cycles for the metadata latency test. 0 disables the throughput/latency benchmark. |
| --bench-type | TYPE | internal | Benchmark engine. internal: built-in smoke test. fio: external fio binary (must be installed and in PATH). |
| --stale-iterations | N | 100 | Number of iterations in the ESTALE probe loop. Higher values increase the chance of catching transient stale handles; 0 disables the probe. |
Output options
| flag | arg | default | description |
|---|---|---|---|
| --json[=PATH] | PATH | — | Emit a hierarchical JSON report. Omit PATH or use - to write to stdout (suppresses diagnostic text). Writing to a file keeps stdout active. |
| --html[=PATH] | PATH | — | Emit a standalone HTML report with inline CSS. Use - for stdout. Suitable for sharing in support tickets or storing as artefacts. |
| --output-format | FMT | text | Terminal output format. text: tagged lines (default). table: UTF-8 box-drawing summary table. ndjson: streaming newline-delimited JSON per event. prometheus: OpenMetrics text at end of run. junit: JUnit XML for CI pipelines (fail → failure, warn → skipped). |
| --listen | [ADDR:]PORT | bind 127.0.0.1 | Serve Prometheus metrics over HTTP (responds to any path, e.g. /metrics). Binds 127.0.0.1 unless ADDR is given — the exporter has no authentication, so exposing it (0.0.0.0:9100, [::]:9100) is an explicit decision. Re-runs diagnostics every --watch SEC (default 60) and serves the latest snapshot between runs. |
| --output-dir | DIR | — | Write a bundle containing JSON, HTML, evidence text, and SHA256 checksums. |
| --keep-temp | — | — | Do not remove the temporary workspace after tests. Useful for manual inspection of leftover test files or mount state. |
| -v, --verbose | — | — | Show all diagnostic steps, including low-level OK and info messages. Without this, only warnings and failures are printed (plus the summary line). |
| -q, --quiet | — | — | Suppress all human stdout (banner, per-check lines, interpretation, and the summary: line) in every output format. Requested reports (--json/--html, --output-format, --output-dir) are still written, including to stdout when the destination is -. |
| -V, --version | — | — | Print nfsdiag <version> and exit 0. |
| --self-test | — | — | Validate local dependencies and pure helper checks, then exit (0 on success, 2 on failure). Used by packaging smoke tests. |
| -h, --help | — | — | Print the help text and exit 0. |
summary:) prints only for the
text and table
formats. Machine formats (ndjson,
junit, prometheus)
and --json=-/--html=-
emit only the structured document. --quiet
removes human stdout in every case while still writing any requested
report to its destination.
nfsdiag server
The server namespace runs on the NFS
server itself and audits its configuration. Its flags and checks are
documented in the
server documentation.
# what it checks
| network |
TCP reachability for rpcbind (tcp/111) and NFS
(tcp/2049). IPv4 and IPv6 with fallback.
Optional UDP probe with --udp.
Measures TCP connect latency (min/avg/max) and the path MTU
towards the server, warning when the MTU is below 1500.
DNS failures are reported with the real resolver error.
|
|---|---|
| rpc |
Full service map from rpcbind: modern rpcbind v3/v4
DUMP (native IPv6, real ports) with legacy
portmapper fallback. NULLPROC probes
for NFS v2, v3, v4, v4.1, v4.2; mountd v1, v2, v3; lockd/NLM;
statd/NSM. Detects which versions are actually registered and
responding. Verifies that dynamically registered
mountd/lockd/statd TCP ports are reachable through firewalls,
and emits a heuristic fingerprint of the server implementation
from its service layout.
|
| client daemons |
Checks for running nfs-client.target,
rpc.gssd, and nfs-idmapd
via systemd. Degrades gracefully on non-systemd systems. With
--krb5: validates Kerberos ticket,
gssd, and krb5.conf.
|
| exports |
Enumerates exports via mountd (tries v3, v2, v1). Falls back to
NFSv4 pseudo-root discovery when mountd is unavailable. Respects
repeatable --export PATH flags to test a
chosen subset, and --parallel N to test
several exports concurrently.
|
| mount |
Attempts to mount each export with version cascade: v4.2 → v4.1
→ v4 → v3. Parses effective mount options from
/proc/self/mountinfo and verifies
they match expected values. Supports private mount namespace
isolation. With --krb5, additionally
tests which Kerberos security flavors
(sec=krb5/krb5i/krb5p) the server
actually accepts at mount time.
|
| filesystem |
Close-to-open consistency check. Read and traverse permission.
Directory listing. Create/write/fsync/read
round-trip. Advanced I/O:
copy_file_range,
fallocate,
O_DIRECT.
Quota and special file detection.
Each group runs with its own SIGALRM
timeout.
|
| acl |
POSIX ACL read/write via getxattr.
NFSv4 ACL detection. Generic extended attribute support.
SELinux security context retrieval.
|
| permissions |
UID and GID simulation using setuid /
setgid / setgroups
in child processes with
prctl(PR_SET_PDEATHSIG) termination
safety. Multiple identities supported via repeated
--uid. Practical
root_squash detection: verifies that
euid 0 is remapped to a non-root UID on the server.
|
| locking |
Advisory lock test via fcntl(F_SETLK).
NLM and NSM service presence. Lock acquisition and release with
timeout protection.
|
| performance |
Internal read/write throughput smoke test (the read pass drops
the client page cache first so it reflects the server). Metadata
latency (create/rename/unlink loop). External
fio backend (opt-in via
--bench-type=fio). Deep latency
histograms and RPC operation stats from
/proc/self/mountstats. RPC
retransmission and auth-refresh delta captured before and after
tests. With --sweep: benchmarks
rsize/wsize/nconnect combinations and recommends the
best-performing mount options.
|
| stale handles |
ESTALE probe loop (configurable
iterations). pNFS layout detection via mountstats. NFSoRDMA
transport hints. NFS minor version tracked in JSON/HTML output.
|
| long filenames |
Creates and accesses files with 255-byte names, names
containing spaces, colons, at-signs, and UTF-8 multibyte
sequences. Reports which the server rejects and with which
errno. Essential for Windows/CIFS
interop and legacy VFS kernels.
|
| delegations |
After mounting, opens and reads a probe file, then parses the
per-mount op table in /proc/self/mountstats
for DELEGRETURN activity. Reports whether NFSv4 delegations were
used, or info if none were observed.
|
| server info |
Reads /proc/fs/nfsfs/servers for the
NFS protocol version, active mount count, and server hostname.
|
| reporting |
Tagged text, UTF-8 box-drawing table, streaming NDJSON,
Prometheus/OpenMetrics, and JUnit XML output. Hierarchical JSON
with per-export events. Standalone HTML with
Content-Security-Policy header and all server-supplied strings
HTML-escaped. Embedded HTTP exporter with
--listen PORT, and per-host baseline
comparison with --diff-baseline.
|
# usage recipes
full diagnostic — needs root for mount
sudo nfsdiag client 192.168.1.10
verbose — see every probe step
sudo nfsdiag -v 192.168.1.10
network and RPC only — no mount, no root
nfsdiag client --no-mount 192.168.1.10
test a single export path
sudo nfsdiag client --export /data 192.168.1.10
pass extra mount options
sudo nfsdiag client --mount-options soft,timeo=30,retrans=2 192.168.1.10
simulate a specific identity (UID/GID + supplemental groups)
sudo nfsdiag \
--uid 1000 --gid 1000 \
--groups 10,20,30 \
192.168.1.10
simulate multiple identities in one run
sudo nfsdiag \
--uid 1000 --gid 1000 \
--uid 2000 --gid 2000 \
192.168.1.10
check Kerberos prerequisites
sudo nfsdiag client --krb5 kerberos-nfs.example.com
machine-readable report for CI / scripts
nfsdiag client --json=report.json 192.168.1.10
JSON to stdout — pipe to jq
nfsdiag client --json 192.168.1.10 | jq '.exports[].events'
shareable HTML for a support ticket
sudo nfsdiag client --html=report.html 192.168.1.10
quiet — report file only, no terminal output
sudo nfsdiag client --quiet --json=report.json 192.168.1.10
production audit — read-only, dry run, rate-limited
nfsdiag client --dry-run --read-only --delay-ms 500 prod-nfs.example.com
isolate in a private mount namespace
sudo nfsdiag client --mount-namespace 192.168.1.10
larger benchmark payload and fio backend
sudo nfsdiag \
--bench-bytes 67108864 \
--bench-type fio \
192.168.1.10
force IPv6, also probe UDP
nfsdiag client --ipv6-only --udp 2001:db8::1
keep temp workspace for post-mortem inspection
sudo nfsdiag client --keep-temp 192.168.1.10
continuous monitoring — re-run every 60 s
sudo nfsdiag client --watch 60 192.168.1.10
audit a fleet of servers from a file
sudo nfsdiag \
--hosts-file /etc/nfs-servers.txt \
--delay-ms 200 \
--json=audit.json
stream events — filter only failures with jq
sudo nfsdiag client --output-format=ndjson 192.168.1.10 \
| jq 'select(.level=="fail")'
Prometheus probe output
sudo nfsdiag client --output-format=prometheus 192.168.1.10
alert via PagerDuty / Slack on failure
sudo nfsdiag \
--on-fail-exec /usr/local/bin/alert.sh \
192.168.1.10
run from a container — no install required
docker run --rm --privileged \
ghcr.io/lsferreira42/nfsdiag client 192.168.1.10
# output formats
Text (default)
Compact on TTY. Warnings and failures always shown. Informational and
low-level OK messages only shown with --verbose.
Coloured output when stdout is a terminal.
nfsdiag 0.22.0: 192.168.0.21
[OK] 1 export(s) discovered
[WARN] root_squash not detected on /data (uid 0 remains 0)
summary: ok=12 warn=1 fail=0
Table
UTF-8 box-drawing summary table printed at end of run. One row per export with columns: path, NFS version, write MiB/s, read MiB/s, metadata p95 ms, lock status, overall status.
sudo nfsdiag client --output-format=table 192.168.1.10
NDJSON (streaming)
One JSON object per event line, emitted as events are generated.
Enables real-time filtering with jq and
integration with log pipelines (Loki, Vector, etc.). Events include
check_id, category,
severity and remediation text.
sudo nfsdiag client --output-format=ndjson 192.168.1.10 \
| jq 'select(.level=="fail")'
Prometheus / OpenMetrics
Emitted at end of run. Per-export metrics: write/read throughput, metadata latency p95, lock result, delegation state, and per-check pass/fail gauge. Suitable for blackbox_exporter or batch-job scraping.
sudo nfsdiag client --output-format=prometheus 192.168.1.10
JSON
Hierarchical structure: top-level metadata, per-export events (linked
by export_idx), per-export NFS version,
and a recommendations array. Each report carries
schema_version (2.0),
timestamp_iso8601,
duration_sec, per-run deterministic
check_id values, event categories, severity,
and remediation text. The output is described by a published JSON
Schema (docs/nfsdiag.schema.json) and follows
a documented 1.x compatibility policy.
# JSON to file — diagnostic text still on stdout
nfsdiag client --json=report.json 192.168.1.10
# JSON to stdout — diagnostic text suppressed
nfsdiag client --json 192.168.1.10
nfsdiag client --json=- 192.168.1.10
# pipe to jq
nfsdiag client --json 192.168.1.10 | jq '.exports[0].nfs_version'
Evidence bundle
--output-dir writes JSON, HTML, evidence
text and SHA256 checksums in one directory. Two JSON reports can be
compared with nfsdiag diff.
sudo nfsdiag client --output-dir ./nfsdiag-report 192.168.1.10
nfsdiag diff before.json after.json
HTML
Standalone report with inline CSS — no external resources. Open in any browser. Safe to email or attach to issue trackers. Includes a Content-Security-Policy header; all server-supplied strings are HTML-escaped.
# HTML to file
sudo nfsdiag client --html=report.html 192.168.1.10
# HTML to stdout (suppresses diagnostic text)
sudo nfsdiag client --html=- 192.168.1.10 > report.html
# quiet: HTML file only, no terminal output
sudo nfsdiag client --quiet --html=report.html 192.168.1.10
# exit codes
| 0 | All checks passed — no warnings, no failures. |
|---|---|
| 1 | At least one warning or failure was detected. In automation, warnings return 1 because they usually need attention. |
| 2 | Usage error or local runtime failure (bad argument, missing dependency, initialization error). |
# docker fixtures
14 Docker fixtures reproduce known-bad NFS situations end-to-end. Each
spins up a container with a specific failure scenario, runs
nfsdiag against it, and validates the
output. Regressions in the diagnostic logic show up as test failures.
insecure, and no_root_squash
intentionally to simulate test scenarios. These settings are
test-only and must never be used in production.
- rpcbind-unreachable
- nfs-port-unreachable
- rpc-map-missing-nfs
- mountd-unavailable
- empty-exports
- mount-denied
- permission-denied
- acl-unsupported
- identity-denied
- read-only-export
- root-squash
- locking-missing
- stale-handle
- slow-performance
make test-fixtures # run all 14 fixtures
make test-fixture-root-squash # run a single fixture
make test-fixture-stale-handle # run another single fixture
make docker-build-all # build all images only
make docker-build-slow-performance # build one image
make test-fixtures-list # list available fixtures
Some fixtures require root because they perform real NFS mounts from the host. The test runner skips mount-dependent cases gracefully if the host cannot run kernel NFS inside Docker.
# packaging
Pre-built .deb, .rpm,
and .apk packages are published automatically
for every release:
github.com/lsferreira42/nfsdiag/releases/latest ↗
Build packages from source
make deb # Debian/Ubuntu .deb (requires dpkg-deb)
make rpm # Fedora/RHEL .rpm (requires rpm-build)
make apk # Alpine .apk (requires Docker)
make packages # all three formats at once
Built packages are placed in build/.
Packaging templates live under packaging/:
OCI Dockerfile, Homebrew formula, AUR PKGBUILD, and Nix flake.
Release artifacts include checksums, SBOM and provenance metadata.
OCI image
A multi-stage Docker image is published to
ghcr.io/lsferreira42/nfsdiag on every
release as :latest and
:vX.Y.Z.
docker run --rm --privileged \
ghcr.io/lsferreira42/nfsdiag client 192.168.1.10
Install options
sudo make install # binary, man page and shell completions to /usr/local
make PREFIX=/opt install # custom prefix
sudo make uninstall # remove all installed files
Version bumping
The version lives in VERSION and is mirrored
atomically into src/nfsdiag.h and all packaging
files by the bump targets.
make bump-version-bugfix # 0.5.0 → 0.5.1
make bump-version-minor # 0.5.0 → 0.6.0
make bump-version-major # 0.5.0 → 1.0.0
GitHub release
make release validates that the working tree
is clean and that VERSION matches
src/nfsdiag.h, then creates and pushes the
annotated v$VERSION tag. It does not build or
upload artifacts itself — the tag push triggers the release workflow,
which is the single builder/publisher. Run
make release-check first to run the build,
unit tests, version/schema checks, cppcheck and shellcheck.
make release-check # gate: build + tests + lint + version/schema
make release # tag v$VERSION and push (workflow publishes)
The GitHub Actions release workflow runs on every
v* tag push. It builds DEB on Ubuntu, RPM in
a Fedora container, and APK via Docker, then attaches all three packages
to the release. After the release exists, run
make update-release-checksums to refresh the
Homebrew and AUR source checksums and commit the result.
Manual compile
gcc -O2 -Wall -Wextra -D_GNU_SOURCE -I/usr/include/tirpc \
src/main.c src/mount.c src/network.c src/report.c \
src/rpc.c src/stats.c src/tests.c src/validation.c src/util.c \
-ltirpc -o nfsdiag
# security
nfsdiag is designed to be safe when run as root, including against adversarial NFS servers.
| file ops |
Report files opened with
O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW|0600
— prevents symlink attacks when running as root. Test files use
hidden .nfsdiag-* names inside a
mktemp-created workspace.
|
|---|---|
| processes |
Child processes resolve helper commands from trusted system
directories and execute them with execve,
an argv[] array, and a minimal
environment — no sh -c with dynamic strings.
prctl(PR_SET_PDEATHSIG) +
setpgid() prevent orphan children.
Inherited file descriptors closed in child processes.
|
| input validation |
Hostnames, export paths, and mount options are validated before
network or mount activity. Exports are mounted with
nosuid,nodev,noexec by default;
risky mount options and disabling that hardening require
--allow-risky-mount-options.
Symlink, hardlink, FIFO, and device-node probes require
--dangerous-fs-tests.
--config files and
--on-fail-exec scripts are refused
when not owned by root/current user or when group/world-writable.
Output captured from external commands is sanitised for terminal
escape sequences before display.
|
| XDR / RPC |
XDR string sizes bounded: export paths at 4096 bytes, group
names at 256 bytes. Export and group lists are decoded
iteratively (no recursion, so no stack-overflow exposure) with
node-count budgets (MAX_XDR_EXPORT_NODES,
MAX_XDR_GROUP_NODES) bounding the
memory a malicious server can force. All XDR strings are
sanitised for control characters before display or HTML inclusion.
|
| random paths |
Test file paths include cryptographically random bytes from
getrandom(). Prevents an attacker
in a shared export from pre-creating a symlink at the predicted
test path to subvert I/O or permission tests.
|
| capabilities |
Child processes that simulate UID/GID clear all ambient
capabilities via
prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL)
before calling setuid(), ensuring
elevated capabilities are not silently retained.
|
| TMPDIR |
The TMPDIR environment variable is
validated before use. The created workspace is checked with
lstat(), must be owned by the current
user, and cleanup refuses paths outside the workspace prefix.
|
| HTML output |
HTML reports include a
Content-Security-Policy meta header.
opt.host and all server-supplied
strings (export paths, group names) are passed through
html_escape() at every insertion
point, preventing stored XSS if the report is opened in a
browser.
|
| memory |
strdup() and
calloc() return values always
checked. NULL on OOM is never stored silently.
|
| timeouts |
SIGALRM-based timeouts protect
both filesystem operations and RPC calls
(clnt_create,
pmap_getmaps). Signal handlers
installed without SA_RESTART for
responsive interruption.
|
| cleanup |
atexit(cleanup_all) + SIGINT/SIGTERM
handlers always unmount and remove the temp workspace, even on
crash or signal. Active mountpoints tracked globally so no mount
leaks on unexpected exit.
|
.nfsdiag-* files to test write/read behaviour.
Use --read-only to leave the export untouched, or
--dry-run to skip mounts entirely.
UID/GID simulation requires root because it uses
setgid, setgroups, and
setuid in child processes.
# architecture
~3,000 lines of C99. Split into 7 focused modules plus a shared header.
No runtime dependencies beyond libtirpc.
Modules
| nfsdiag.h | All shared types, constants, and extern declarations. Single source of truth for defaults and limits. |
|---|---|
| main.c | CLI parsing (getopt_long), orchestration loop, signal and atexit handlers. |
| network.c | TCP port reachability checks for IPv4 and IPv6. |
| rpc.c | RPC service discovery via rpcbind, NULLPROC version probing, export enumeration via mountd. |
| mount.c | Mount/unmount execution, command output capture via poll(), mount namespace setup. |
| tests.c | All filesystem-level diagnostics after mount: I/O, ACLs, locks, identity simulation, benchmarks. |
| report.c | Event and recommendation accumulation, JSON and HTML report generation. |
| stats.c | Client daemon checks, /proc/net/rpc/nfs stats, /proc/self/mountstats parsing. |
Execution flow
CLI args → network tests → RPC discovery → export enumeration
→ [for each export] mount (v4.2→4.1→4→3) → filesystem tests → unmount
→ RPC stats delta → text/JSON/HTML report → cleanup
Key constants
| constant | value | ||
|---|---|---|---|
| NFSDIAG_VERSION | "0.22.0" | ||
| MAX_XDR_EXPORT_NODES | 2048 | ||
| MAX_XDR_GROUP_NODES | 8192 | ||
| MAX_HOSTS | 256 | ||
| RPCBIND_PORT | 111 | ||
| NFS_PORT | 2049 | ||
| MAX_EXPORTS | 512 | ||
| MAX_EVENTS | 4096 | ||
| MAX_RECOMMENDATIONS | 128 | ||
| MAX_MOUNTPOINTS | 128 | ||
| MAX_IDENTITIES | 32 | ||
| MAX_SUPP_GROUPS | 64 | ||
| MAX_XDR_EXPORT_PATH | 4096 | ||
| MAX_XDR_GROUP_NAME | 256 | ||
| CMD_OUTPUT_LIMIT | 8192 | ||
| DEFAULT_TIMEOUT_SEC | 5 | ||
| DEFAULT_COMMAND_TIMEOUT_SEC | 30 | ||
| DEFAULT_FS_TIMEOUT_SEC | 30 | ||
| DEFAULT_BENCH_BYTES | 4 MiB | ||
| DEFAULT_BENCH_ITERATIONS | 10 | ||
| DEFAULT_STALE_ITERATIONS | 100 | ||
# limitations
NFS problems are environment-dependent. Results can differ based on firewall rules, server export options, NFS version negotiated, kernel client state, UID/GID mapping, root squash, ACLs, SELinux/AppArmor on the server, and server load.
Things the client-side tool cannot guarantee:
ESTALEwindows that only open under production load- SELinux / AppArmor denials that surface as plain
EACCES - ACL info the kernel client chooses not to expose
- Performance numbers beyond smoke-test territory
- Docker NFS fixtures depend on host kernel and Docker privileges
- Server-side issues: disk full, export re-exports, NFS server logs
Use nfsdiag as a fast diagnostic helper, not as the sole source of truth.
Server-side analysis (NFS server logs, exportfs -v,
nfsstat -s) remains essential.
# see also
# changelog
v0.15.0
Security pass for the server namespace: exports hardening analysis, identity mapping, Kerberos, ACLs, and a practical root_squash test.
- New server checks:
--security-audit,--idmap-check,--krb5-server,--acl-check— all part of--all --squash-checkmounts each export from localhost and reports the effective root mapping; intrusive, opt-in, root only--audit-trailcaptures config snapshots (exports, nfs.conf, idmapd.conf, krb5.conf) withCONFIG.SHA256SUMSinto--output-dir
v0.14.0
Foundation of the server namespace: five local checks, structured output, and offline diagnosis of copied filesystem trees.
- New server checks:
--daemons,--ports-firewall,--storage-health,--version-matrix,--sysctl-advisor;--allruns everything server --root DIRreads/procand/etcunder a copied tree (an extracted sosreport); live-only steps are skipped with an explanation- The server namespace gained the shared output flags:
--json,--html,--output-format,--output-dir - Unified report pipeline for server runs: same banner, summary and structured reports as the client; OK/INFO lines print by default in server mode
v0.13.0
Subcommand namespaces and the first server-side check: the CLI splits
into client and
server, opening the road to full
server-side NFS diagnostics.
- CLI reorganized as
nfsdiag client(everything that existed before),nfsdiag server(runs on the NFS server itself), andnfsdiag diff - New
server --exports-audit(with--exports-file): flags exports syntax errors, missing client lists, and risky options such asno_root_squash,insecure, and world-writable wildcards - JSON reports carry an optional
modefield (client/server); existing consumers keep validating - Shell completions complete the subcommand first, then per-namespace flags
- Deprecated: calling
nfsdiag [OPTIONS] <host>without a subcommand — still works as an alias forclientwith a warning on stderr; removal planned for 1.0
v0.12.0
Correctness, hardening, and performance pass: accurate latency reporting, a safer embedded exporter, symlink-resistant file reads, and lower memory use.
- mountstats per-op latency is now reported in milliseconds (was ~1000x too low); TCP connect latency excludes DNS and the path-MTU probe reuses the connection
- Embedded exporter (
--listen) adds a per-connection timeout so a silent client can't wedge it, serves metrics only onGET /metrics(and/), and returns404/405otherwise --profilenow sets defaults only — an explicit flag always wins regardless of order; default hardening options are no longer duplicated when passed via-o- Report/baseline reads use
O_NOFOLLOW+ regular-file checks; reused mountpoints are validated; write probes are skipped when no secure entropy source is available - Table output truncates export paths on UTF-8 boundaries;
mountinfooctal escapes are decoded before matching - NDJSON streams through one descriptor, per-export storage is allocated on demand (~2.8 MB less memory), and
/proc/self/mountstatsis read once per export - New
make strictandmake compile-commandsdeveloper targets
v0.11.0
Road-to-1.0 hardening: safer diagnostics, a published JSON schema and stability policy, real-parser fuzzing, and a much wider CI and packaging gate.
- Per-export filesystem tests run in a killable child with a hard deadline (sequential and parallel), so a wedged hard mount can't pin the tool
- Severity is server-profile aware: on an NFSv4-only server, missing mountd/NLM/NSM/NFSv3 is
info, notwarn; the version cascade emits a singlefailonly when no version mounts --quietnow silences human stdout in every format;--bench-bytes 0is rejected and--bench-iterations 0/--stale-iterations 0mean "disabled"--uid/--gid/--groupsreject out-of-range values; special-filename probes use random names +O_CREAT|O_EXCLso pre-existing files are never touched- Published JSON schema (
docs/nfsdiag.schema.json) with a documented 1.x compatibility policy; validated in CI - Fuzzers now drive the real
mountstats/mountinfo/rpcstatsparsers; newFUZZ_SANITIZERS/FUZZ_RUNSknobs - CI gained DEB/RPM/APK install smoke, AUR
makepkg/namcap, Nixflake check,promtool, website and signal checks, plus a post-publish release smoke test - New docs: threat model, compatibility policy, and integration recipes; Homebrew formula is Linux-only
v0.10.0
Pre-1.0 hardening release: governance files, packaging completeness, release-pipeline automation, machine-output correctness, and a much larger CI gate.
LICENSE(MIT),SECURITY.md, and a canonicalCHANGELOG.mdadded to the repository--listennow binds127.0.0.1by default and accepts[ADDR:]PORT(the exporter has no authentication; exposing it is opt-in)nfsdiag diffand the--deepalias are documented in--help- Machine outputs are clean: the version banner and
summary:line no longer corrupt--json=-, ndjson, prometheus, or junit streams - Numeric arguments reject negative values (previously wrapped via
strtoul); empty mount-option tokens are rejected; parser errors show the valid range - deb/rpm/apk now ship man page, completions, and license; OCI image carries labels, license, and man page
- Release artifacts get signed Sigstore provenance attestations; GitHub Actions pinned by commit SHA
- CI: cppcheck reinstated, ASan/UBSan + valgrind, fuzz smoke, shellcheck, output-format validation against the published JSON schema, a rootless docker fixture subset, coverage, and version/docs consistency checks
- Nix flake moved to the repo root and builds in pure evaluation mode; AUR PKGBUILD validated and pinned with a real sha256
v0.9.0
Feature release: parallel export testing, mount option sweep, Kerberos flavor probing, JUnit output, embedded Prometheus exporter, baseline comparison, deeper network diagnostics, server fingerprinting, and native IPv6 rpcbind — on top of the security hardening and correctness fixes below.
--exportis now repeatable (up to 64 paths) to test a chosen subset of exports--parallel N— test up to 32 exports concurrently in forked workers, with results merged into the normal reports--sweep— benchmark rsize/wsize/nconnect combinations on a working export and recommend the best mount options (also enabled by--profile performance)--krb5now also mounts withsec=krb5,krb5i, andkrb5pto report which flavors the server accepts--output-format junit— JUnit XML for CI pipelines (fail → failure, warn → skipped)--listen PORT— embedded HTTP exporter serving Prometheus metrics, refreshing diagnostics every--watchseconds--diff-baseline— keep a per-host baseline under~/.local/share/nfsdiagand report regressions against the previous run- Network checks now measure TCP connect latency and path MTU, and verify reachability of dynamically registered mountd/lockd/statd ports
- Heuristic server implementation fingerprint from the RPC service layout (knfsd-style, NFSv4-only, fixed-port appliance, v3-only)
- rpcbind v3/v4
DUMPsupport: full service map with real ports over IPv6, replacing blind program probing when available
- Exports are now mounted with
nosuid,nodev,noexecby default;--allow-risky-mount-optionsdisables the hardening - Identity simulation always resets supplemental groups before
setuid(), so access results reflect the simulated user instead of root's groups - Evidence and
SHA256SUMSfiles in--output-dirare created withO_NOFOLLOWand mode 0600 (no more symlink-followingfopen) - Output captured from external commands (mount, klist, …) is sanitised for terminal escape sequences before display
--on-fail-execrefuses scripts not owned by root/current user or writable by group/others--configrefuses files not owned by root/current user or writable by group/others- Benchmark test file is created with
O_EXCL, closing a hardlink pre-creation window on shared exports - Random test-path generation falls back to
/dev/urandombefore the weak time/pid fallback - Mount-options values are redacted from the argv recorded in the evidence file
--output-dirpermission fix-up now usesopen(O_DIRECTORY|O_NOFOLLOW)+fchmod(TOCTOU removed);nfsdiag diffsummary parsing is anchored to thesummaryobject- Export/group XDR lists are decoded iteratively: servers with more than ~32 exports no longer fail enumeration (the old recursion depth cap limited list length), and node budgets still bound allocations
--config=FILE(equals form) is now recognised; previously only--config FILEloaded the file- Partially decoded export lists are freed when the mountd EXPORT call fails (memory leak fixed)
- Read benchmark drops the client page cache (
posix_fadvise DONTNEED) before reading back, so the number reflects the server; if the cache cannot be dropped the result is labelled - Filesystem-timeout disarm no longer touches an uninitialised
sigactionwhen timeouts are disabled - Colour output is re-evaluated per line instead of cached once, fixing stale colour decisions in watch/hosts-file modes after stdout redirection
nfsdiag diffreads whole reports instead of truncating at 64 KiB- Metadata latency test files now use the same randomised naming as all other test files
- DNS failures are reported with the real
getaddrinfoerror ("Name or service not known", …) instead of a generic "unreachable" per port - Config-supplied
bench-type/output-dirstrings no longer leak on reload
v0.8.0
Skipped — no release carries this version number.
v0.7.0
Maintenance release: fixture, wording, and robustness fixes. No new features.
- Docker fixture images and the kernel NFS entrypoint hardened; fixture docs updated
- Wording and message consistency across modules
- Minor robustness fixes in mount handling, report generation, and stats parsing
- CI workflow cleanup;
make bump-packagingextended to cover the website
v0.6.1
Release tooling: make release now publishes
every artifact it can build, not just the packages.
make releasenow uploads the standalone binary, SBOM, and aSHA256SUMSfile alongside the deb/rpm/apk packages- New
make binary-disttarget stages a versioned, arch-named binary (nfsdiag-<version>-linux-<arch>) inbuild/ make packagesnow also produces the standalone binary
v0.6.0
Correctness release: fixes to delegation and server-info detection, per-export status, Prometheus output, and filesystem-test timeouts.
- NFSv4 delegation detection rewritten to read DELEGRETURN activity from
/proc/self/mountstats(the previous/proc/fs/nfsfs/volumesmatch never fired) /proc/fs/nfsfs/serversparsing fixed to the realNV SERVER PORT USE HOSTNAMEformat (reports protocol version, active mounts, hostname; the old lease-time field does not exist in that file)- Per-export status in the HTML and table reports now reflects each export's own events instead of the global pass/fail counters
- Prometheus output emits each
# HELP/# TYPEonce, fixing invalid duplicate lines when more than one export is tested - All filesystem probes (special files, xattrs, copy_file_range, fallocate/O_DIRECT, close-to-open, long filenames, delegation) now run under a
--fs-timeoutguard --hosts-filenow warns when a positional host is also supplied and ignored--exportpath allocation failure is handled instead of silently using a NULL path- Watch-mode banner text corrected
v0.5.0
Major feature release: new output modes, multi-host support, watch mode, on-fail hooks, config file, new filesystem checks, OCI image, shell completions, man page, and security hardening.
--output-format=table/ndjson/prometheus— three new terminal output modes--watch SEC— continuous monitoring mode with clean SIGINT handling--hosts-file FILE— batch diagnostics across a fleet--on-fail-exec SCRIPT— execvp-based failure hook (PagerDuty, Slack, etc.)--config FILE— persistent key=value configuration file- Long filename test: 255-byte names, spaces, colons, UTF-8 multibyte
- NFSv4 delegation detection via
/proc/fs/nfsfs/volumes /proc/fs/nfsfs/serversparsing — lease time warning if < 30 s- OCI image published to
ghcr.io/lsferreira42/nfsdiag - Shell completions for bash, zsh, and fish
- Man page
docs/nfsdiag.8 - CI matrix: Ubuntu 24.04, Fedora 41, Debian 12, Alpine 3.21
- ASan + UBSan CI job; cppcheck and clang-tidy static-analysis jobs
- linux-arm64 cross-compiled binary in GitHub Releases
- Fuzzer stubs for XDR, mountstats, mountinfo, RPC stats parsers
make coveragetarget for gcov/lcov coverage- Security: getrandom() random test paths, ambient capability clearing, XDR depth limit, TMPDIR validation, HTML CSP header
- Fix: cleanup_all() async-signal-safety (sigprocmask before stdio)
v0.4.1
Website and CI/CD infrastructure. No changes to diagnostic behaviour.
- Added website (
website/) served at www.nfsdiag.org - Home page (
index.html) with install, checks overview, and basic usage - Full documentation page (
docs.html) with complete CLI reference, fixtures, packaging, security, architecture, and changelog - Added
wrangler.jsoncto deploy website as Cloudflare Workers static assets - Added GitHub Actions workflow (
deploy-website.yml) for automatic deployment on push tomain - Fixed
make cleantarget
v0.3.0
Packaging and release infrastructure. No breaking changes to diagnostic behaviour.
- Added
make deb,make rpm,make apkpackaging targets - Added
make releasefor automated GitHub releases viagh - Added
make bump-version-{bugfix,minor,major}for atomic version bumps - GitHub Actions workflow builds DEB/RPM/APK on every
v*tag - VERSION file as single source of truth, mirrored to header and packaging files
v0.2.0
Security fixes, behavioural fixes, and reliability improvements.
Security & crash fixes
strdup()return checked inadd_event()andadd_recommendation()— prevents NULL dereference under OOM- Report files opened with
O_NOFOLLOW|O_CREAT|0600— prevents symlink attacks when running as root - XDR string limits applied to export paths (4096 B) and group names (256 B) — prevents memory exhaustion from malicious servers
fiobenchmarks useexecvpargv array instead ofsh -c- Numeric CLI arguments reject empty strings —
--uid=is now an error
Behavioural fixes
--json=fileno longer suppresses stdout; use--quietto suppress--html=-now suppresses diagnostic text correctly--dry-runno longer runs filesystem diagnostics on local temp dir- Write/read benchmark, advisory lock, and root_squash are now independent tests with separate timeouts
enumerate_exports()now tries mountd v2 between v3 and v1- IPv6 literal addresses get brackets in mount source (
[addr]:/export) - NFS minor version (4.1, 4.2) tracked in JSON and HTML reports
Reliability fixes
- Pipe drain continues discarding when output buffer is full — no stall-until-timeout
dup2()return checked in child processessscanf()return checked when parsing/proc/net/rpc/nfs- RPC counter wrap/reset detected when computing stats delta
clnt_create()andpmap_getmaps()protected with SIGALRM timeout- Mountpoint option detection uses exact comma-separated token matching
- Mountstats section matched by exact mountpoint field, not substring
- Signal handlers installed without
SA_RESTART
Other
- All 14 fixtures included in
ALL_FIXTURES(was 9) - Test runner uses
mktempinstead of predictable/tmpfilenames TMPDIRenvironment variable respected for temp workspace- Client daemon checks skipped gracefully on non-systemd systems
-V, --versionflag added- CLI options reorganised into logical groups (Diagnostic / Timeout / Benchmark / Output)
v0.1.0
Initial public release.