Files
seaweedfs/weed/server/nfs/portmap.go
Chris LuandGitHub 35fe3c801b feat(nfs): UDP MOUNT v3 responder + real-Linux e2e mount harness (#9267)
* feat(nfs): add UDP MOUNT v3 responder

The upstream willscott/go-nfs library only serves the MOUNT protocol
over TCP. Linux's mount.nfs and the in-kernel NFS client default
mountproto to UDP in many configurations, so against a stock weed nfs
deployment the kernel queries portmap for "MOUNT v3 UDP", gets port=0
("not registered"), and either falls back inconsistently or surfaces
EPROTONOSUPPORT — surfacing as the user-visible "requested NFS version
or transport protocol is not supported" reported in #9263. The user has
to add `mountproto=tcp` or `mountport=2049` to mount options to coerce
TCP just for the MOUNT phase.

Add a small UDP responder that speaks just enough of MOUNT v3 to handle
the procedures the kernel actually invokes during mount setup and
teardown: NULL, MNT, and UMNT. The wire layout for MNT mirrors
handler.go's TCP path so both transports produce the same root
filehandle and the same auth flavor list for the same export. Other
v3 procedures (DUMP, EXPORT, UMNTALL) cleanly return PROC_UNAVAIL.

This commit only adds the responder; portmap-advertise and Server.Start
wire-up follow in subsequent commits so each step stays independently
reviewable.

References: RFC 1813 §5 (NFSv3/MOUNTv3), RFC 5531 (RPC). Existing
constants and parseRPCCall / encodeAcceptedReply helpers from
portmap.go are reused so behaviour stays consistent across both UDP
listening goroutines.

* feat(nfs): advertise UDP MOUNT v3 in the portmap responder

The portmap responder advertised TCP-only entries because go-nfs only
serves TCP, but with the new UDP MOUNT responder in place we can now
honestly advertise MOUNT v3 over UDP as well. Linux clients whose
default mountproto is UDP query portmap during mount setup; if the
answer is "not registered" some kernels translate the result to
EPROTONOSUPPORT instead of falling back to TCP, which is exactly the
failure pattern reported in #9263.

Add the entry, refresh the doc comment, and extend the existing
GETPORT and DUMP unit tests so a regression that drops the entry shows
up at unit-test granularity rather than only in an end-to-end mount.

* feat(nfs): start UDP MOUNT v3 responder alongside the TCP NFS listener

Plug the new mountUDPServer into Server.Start so it comes up on the
same bind/port as the TCP NFS listener. Started before portmap so a
portmap query that races a fast client never returns a UDP MOUNT entry
the responder isn't actually answering, and shut down via the same
defer chain so a portmap-or-listener startup failure doesn't leave the
UDP responder dangling.

The portmap startup log now reflects all three advertised entries
(NFS v3 tcp, MOUNT v3 tcp, MOUNT v3 udp) so operators can confirm at a
glance that the UDP MOUNT path is up.

Verified end-to-end: built a Linux/arm64 binary, ran weed nfs in a
container with -portmap.bind, and mounted from another container using
both the user-reported failing setup from #9263 (vers=3 + tcp without
mountport) and an explicit mountproto=udp to force the new code path.
The trace `mount.nfs: trying ... prog 100005 vers 3 prot UDP port 2049`
now leads to a successful mount instead of EPROTONOSUPPORT.

* docs(nfs): note that the plain mount form works on UDP-default clients

With UDP MOUNT v3 now served alongside TCP, the only path that ever
required mountproto=tcp / mountport=2049 — clients whose default
mountproto is UDP — works against the plain mount example. Update the
startup mount hint and the `weed nfs` long help so users don't go
hunting for a mount-option workaround that no longer applies.

The "without -portmap.bind" branch is unchanged: that path still has
to bypass portmap entirely because there is no portmap responder for
the kernel to query.

* test(nfs): add kernel-mount e2e tests under test/nfs

The existing test/nfs/ harness boots a real master + volume + filer +
weed nfs subprocess stack and drives it via go-nfs-client. That covers
protocol behaviour from a Go client's perspective, but anything
mis-coded once a real Linux kernel parses the wire bytes is invisible:
both ends of the test use the same RPC library, so identical bugs
round-trip cleanly. The two NFS issues hit recently were exactly that
shape — NFSv4 mis-routed to v3 SETATTR (#9262) and missing UDP MOUNT v3
— and only surfaced in a real client.

Add three end-to-end tests that mount the harness's running NFS server
through the in-tree Linux client:

  - TestKernelMountV3TCP: NFSv3 + MOUNT v3 over TCP (baseline).
  - TestKernelMountV3MountProtoUDP: NFSv3 over TCP, MOUNT v3 over UDP
    only — regression test for the new UDP MOUNT v3 responder.
  - TestKernelMountV4RejectsCleanly: vers=4 against the v3-only server,
    asserting the kernel surfaces a protocol/version-level error rather
    than a generic "mount system call failed" — regression test for the
    PROG_MISMATCH path from #9262.

The tests pass explicit port=/mountport= mount options so the kernel
never queries portmap, which means the harness doesn't need to bind
the privileged port 111 and won't collide with a system rpcbind on a
shared CI runner. They t.Skip cleanly when the host isn't Linux, when
mount.nfs isn't installed, or when the test process isn't running as
root.

Run locally with:

	cd test/nfs
	sudo go test -v -run TestKernelMount ./...

CI wiring follows in the next commit.

* ci(nfs): run kernel-mount e2e tests in nfs-tests workflow

Wire the new TestKernelMount* tests from test/nfs into the existing
NFS workflow:

  - Existing protocol-layer step now skips '^TestKernelMount' so a
    "skipped because not root" line doesn't appear on every run.
  - New "Install kernel NFS client" step pulls nfs-common (mount.nfs +
    helpers) and netbase (/etc/protocols, which mount.nfs's protocol-
    name lookups need to resolve `tcp`/`udp`).
  - New privileged step runs only the kernel-mount tests under sudo,
    preserving PATH and pointing GOMODCACHE/GOCACHE at the user's
    caches so the second `go test` invocation reuses already-built
    test binaries instead of redownloading modules under root.

The summary block now lists the three kernel-mount cases explicitly
so a regression on either of #9262 or this PR's UDP MOUNT change is
traceable from the workflow run page.
2026-04-28 14:06:35 -07:00

448 lines
13 KiB
Go

package nfs
import (
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
// Minimal PORTMAP v2 responder.
//
// The upstream willscott/go-nfs library serves NFSv3 and MOUNT on a single TCP
// port and deliberately does not register with portmap (RPC program 100000).
// Linux mount.nfs, however, queries portmap on port 111 before sending the
// MOUNT RPC, so the plain `mount -t nfs host:/export /mnt` command fails
// against a default `weed nfs` deployment.
//
// When enabled, this responder binds the privileged port 111 (RFC 1833) on
// both TCP and UDP and answers the subset of PORTMAP v2 calls that standard
// Linux clients make: PMAP_NULL, PMAP_GETPORT and PMAP_DUMP. It refuses
// registration from third parties (PMAP_SET / PMAP_UNSET return false) and
// only exposes the programs that weed itself serves.
//
// References: RFC 1833 (Portmap v2), RFC 5531 (RPC).
const (
portmapProgram = 100000
portmapVersion = 2
portmapPort = 111
pmapProcNull = 0
pmapProcSet = 1
pmapProcUnset = 2
pmapProcGetPort = 3
pmapProcDump = 4
ipProtoTCP = 6
ipProtoUDP = 17
nfsProgram = 100003
mountProgram = 100005
// RPC
rpcMsgCall = 0
rpcMsgReply = 1
rpcMsgAccepted = 0
rpcAcceptSuccess = 0
rpcAcceptProgUnavail = 1
rpcAcceptProgMismatch = 2
rpcAcceptProcUnavail = 3
rpcAcceptGarbageArgs = 4
rpcAuthNone = 0
// Defensive limits. Portmap messages are tiny in practice; these caps
// protect the responder from large or slow reads.
portmapMaxRecord = 64 * 1024
// Per-connection read/write deadlines on the TCP listener. The idle
// timeout bounds how long we wait for the next request on an otherwise
// quiet connection; the IO timeout bounds a single read or write once
// one is in flight. Both guard against slowloris-style stalls on the
// privileged port 111.
portmapTCPIdleTimeout = 30 * time.Second
portmapTCPIOTimeout = 10 * time.Second
// Back-off applied before retrying after a non-fatal listener error
// (e.g. EMFILE on TCP Accept, or a transient UDP read failure) so we
// don't busy-loop when the host is under pressure.
portmapRetryBackoff = 50 * time.Millisecond
)
type portmapEntry struct {
Program uint32
Version uint32
Protocol uint32
Port uint32
}
type portmapServer struct {
bindIP string
port int
entries []portmapEntry
tcpListener net.Listener
udpConn *net.UDPConn
// mu guards closed and conns. It is held only for bookkeeping, never
// across network IO.
mu sync.Mutex
closed bool
conns map[net.Conn]struct{}
// done is closed exactly once by Close() so that background loops can
// interrupt a retry-backoff sleep instead of waiting it out.
done chan struct{}
wg sync.WaitGroup
}
// newPortmapServer builds a responder advertising the NFS services the caller
// runs on nfsPort. NFS itself is TCP-only here (the upstream go-nfs library
// doesn't speak NFS UDP). MOUNT, however, is served over both TCP (via
// go-nfs) and UDP (via mountUDPServer in mount_udp.go), so we advertise
// both — that's what makes plain `mount -t nfs <host>:<export> /mnt` work
// against Linux clients whose default mountproto is UDP without needing
// mountproto=tcp / mountport=2049 mount options.
func newPortmapServer(bindIP string, port int, nfsPort uint32) *portmapServer {
if port <= 0 {
port = portmapPort
}
return &portmapServer{
bindIP: bindIP,
port: port,
done: make(chan struct{}),
entries: []portmapEntry{
{Program: nfsProgram, Version: 3, Protocol: ipProtoTCP, Port: nfsPort},
{Program: mountProgram, Version: 3, Protocol: ipProtoTCP, Port: nfsPort},
{Program: mountProgram, Version: 3, Protocol: ipProtoUDP, Port: nfsPort},
},
}
}
func (ps *portmapServer) Start() error {
addr := net.JoinHostPort(ps.bindIP, fmt.Sprintf("%d", ps.port))
tcpLn, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("portmap tcp listen %s: %w", addr, err)
}
udpAddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
_ = tcpLn.Close()
return fmt.Errorf("portmap udp resolve %s: %w", addr, err)
}
udpConn, err := net.ListenUDP("udp", udpAddr)
if err != nil {
_ = tcpLn.Close()
return fmt.Errorf("portmap udp listen %s: %w", addr, err)
}
ps.tcpListener = tcpLn
ps.udpConn = udpConn
ps.wg.Add(2)
go func() {
defer ps.wg.Done()
ps.serveTCP()
}()
go func() {
defer ps.wg.Done()
ps.serveUDP()
}()
return nil
}
func (ps *portmapServer) Close() error {
ps.mu.Lock()
if ps.closed {
ps.mu.Unlock()
return nil
}
ps.closed = true
conns := ps.conns
ps.conns = nil
close(ps.done)
ps.mu.Unlock()
var first error
if ps.tcpListener != nil {
if err := ps.tcpListener.Close(); err != nil {
first = err
}
}
if ps.udpConn != nil {
if err := ps.udpConn.Close(); err != nil && first == nil {
first = err
}
}
// Evict in-flight TCP handlers so Close() does not block on idle
// clients; their read goroutines will unwind on the closed conn.
for c := range conns {
_ = c.Close()
}
ps.wg.Wait()
return first
}
func (ps *portmapServer) isClosed() bool {
ps.mu.Lock()
defer ps.mu.Unlock()
return ps.closed
}
// addConn registers c for shutdown eviction. It returns false (and the
// caller must drop c) if the server has already started shutting down.
func (ps *portmapServer) addConn(c net.Conn) bool {
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.closed {
return false
}
if ps.conns == nil {
ps.conns = make(map[net.Conn]struct{})
}
ps.conns[c] = struct{}{}
return true
}
func (ps *portmapServer) removeConn(c net.Conn) {
ps.mu.Lock()
defer ps.mu.Unlock()
delete(ps.conns, c)
}
func (ps *portmapServer) serveTCP() {
for {
conn, err := ps.tcpListener.Accept()
if err != nil {
if ps.isClosed() {
return
}
// Non-fatal (e.g. EMFILE, EINTR): log and back off rather
// than tear the listener down on a transient resource blip.
// Wake early if Close() fires during the sleep.
glog.V(1).Infof("portmap tcp accept: %v", err)
select {
case <-ps.done:
return
case <-time.After(portmapRetryBackoff):
continue
}
}
if !ps.addConn(conn) {
_ = conn.Close()
continue
}
ps.wg.Add(1)
go func(c net.Conn) {
defer ps.wg.Done()
defer ps.removeConn(c)
ps.handleTCPConn(c)
}(conn)
}
}
func (ps *portmapServer) handleTCPConn(conn net.Conn) {
defer conn.Close()
hdr := make([]byte, 4)
for {
_ = conn.SetReadDeadline(time.Now().Add(portmapTCPIdleTimeout))
if _, err := io.ReadFull(conn, hdr); err != nil {
return
}
mark := binary.BigEndian.Uint32(hdr)
// Bit 31: last-fragment flag. Portmap messages are always single
// fragment in practice; drop the connection if we see otherwise.
if mark&(1<<31) == 0 {
return
}
recLen := mark &^ (1 << 31)
if recLen == 0 || recLen > portmapMaxRecord {
return
}
buf := make([]byte, recLen)
_ = conn.SetReadDeadline(time.Now().Add(portmapTCPIOTimeout))
if _, err := io.ReadFull(conn, buf); err != nil {
return
}
reply := ps.handleCall(buf)
if reply == nil {
continue
}
out := make([]byte, 4+len(reply))
binary.BigEndian.PutUint32(out[0:4], uint32(len(reply))|(1<<31))
copy(out[4:], reply)
_ = conn.SetWriteDeadline(time.Now().Add(portmapTCPIOTimeout))
if _, err := conn.Write(out); err != nil {
return
}
}
}
func (ps *portmapServer) serveUDP() {
buf := make([]byte, portmapMaxRecord)
for {
n, addr, err := ps.udpConn.ReadFromUDP(buf)
if err != nil {
if ps.isClosed() {
return
}
// Transient read failure: log, back off, and keep the
// responder alive instead of taking UDP portmap down.
// Wake early if Close() fires during the sleep.
glog.V(1).Infof("portmap udp read: %v", err)
select {
case <-ps.done:
return
case <-time.After(portmapRetryBackoff):
continue
}
}
reply := ps.handleCall(buf[:n])
if reply == nil {
continue
}
if _, err := ps.udpConn.WriteToUDP(reply, addr); err != nil {
glog.V(1).Infof("portmap udp write to %s: %v", addr, err)
}
}
}
// handleCall parses one RPC CALL message and returns the encoded reply, or nil
// if the call is malformed enough that we should drop it silently.
func (ps *portmapServer) handleCall(callBuf []byte) []byte {
xid, prog, vers, proc, args, err := parseRPCCall(callBuf)
if err != nil {
return nil
}
if prog != portmapProgram {
return encodeAcceptedReply(xid, rpcAcceptProgUnavail, nil)
}
if vers != portmapVersion {
// Program-version mismatch: RFC 5531 says we should return the
// accepted range; keep it simple and report 2..2.
body := make([]byte, 8)
binary.BigEndian.PutUint32(body[0:4], portmapVersion)
binary.BigEndian.PutUint32(body[4:8], portmapVersion)
return encodeAcceptedReply(xid, rpcAcceptProgMismatch, body)
}
switch proc {
case pmapProcNull:
return encodeAcceptedReply(xid, rpcAcceptSuccess, nil)
case pmapProcGetPort:
if len(args) < 16 {
return encodeAcceptedReply(xid, rpcAcceptGarbageArgs, nil)
}
q := portmapEntry{
Program: binary.BigEndian.Uint32(args[0:4]),
Version: binary.BigEndian.Uint32(args[4:8]),
Protocol: binary.BigEndian.Uint32(args[8:12]),
}
port := uint32(0)
for _, e := range ps.entries {
if e.Program == q.Program && e.Version == q.Version && e.Protocol == q.Protocol {
port = e.Port
break
}
}
body := make([]byte, 4)
binary.BigEndian.PutUint32(body, port)
return encodeAcceptedReply(xid, rpcAcceptSuccess, body)
case pmapProcDump:
// Each entry is 4-byte value_follows + 16-byte mapping = 20 bytes,
// plus a 4-byte terminator value_follows=FALSE.
body := make([]byte, 0, 20*len(ps.entries)+4)
for _, e := range ps.entries {
chunk := make([]byte, 20)
binary.BigEndian.PutUint32(chunk[0:4], 1) // value_follows = TRUE
binary.BigEndian.PutUint32(chunk[4:8], e.Program)
binary.BigEndian.PutUint32(chunk[8:12], e.Version)
binary.BigEndian.PutUint32(chunk[12:16], e.Protocol)
binary.BigEndian.PutUint32(chunk[16:20], e.Port)
body = append(body, chunk...)
}
end := make([]byte, 4) // value_follows = FALSE
body = append(body, end...)
return encodeAcceptedReply(xid, rpcAcceptSuccess, body)
case pmapProcSet, pmapProcUnset:
// Don't accept third-party registrations. bool=FALSE.
body := make([]byte, 4)
return encodeAcceptedReply(xid, rpcAcceptSuccess, body)
default:
return encodeAcceptedReply(xid, rpcAcceptProcUnavail, nil)
}
}
// parseRPCCall parses the fixed portion of an RPC CALL header and returns the
// remaining procedure arguments. It skips both opaque_auth fields (cred and
// verf) so callers get a buffer starting at the procedure arguments.
func parseRPCCall(buf []byte) (xid, prog, vers, proc uint32, args []byte, err error) {
// Minimum header: xid + msg_type + rpcvers + prog + vers + proc + 2x
// (flavor + len) = 6*4 + 2*8 = 40 bytes.
const minHeader = 40
if len(buf) < minHeader {
err = fmt.Errorf("rpc call too short: %d bytes", len(buf))
return
}
xid = binary.BigEndian.Uint32(buf[0:4])
if msgType := binary.BigEndian.Uint32(buf[4:8]); msgType != rpcMsgCall {
err = fmt.Errorf("not an rpc call: msg_type=%d", msgType)
return
}
if rpcvers := binary.BigEndian.Uint32(buf[8:12]); rpcvers != 2 {
err = fmt.Errorf("unsupported rpc version %d", rpcvers)
return
}
prog = binary.BigEndian.Uint32(buf[12:16])
vers = binary.BigEndian.Uint32(buf[16:20])
proc = binary.BigEndian.Uint32(buf[20:24])
p := 24
for i := 0; i < 2; i++ {
if len(buf) < p+8 {
err = fmt.Errorf("truncated opaque_auth at offset %d", p)
return
}
authLen := binary.BigEndian.Uint32(buf[p+4 : p+8])
// Validate before applying the XDR 4-byte padding so that
// lengths near uint32 max can't wrap to a tiny padded value.
if authLen > uint32(portmapMaxRecord) {
err = errors.New("opaque_auth length exceeds limit")
return
}
padded := (authLen + 3) &^ 3
end := uint64(p) + 8 + uint64(padded)
if end > uint64(len(buf)) {
err = fmt.Errorf("truncated opaque_auth body at offset %d (len=%d)", p, authLen)
return
}
p = int(end)
}
args = buf[p:]
return
}
// encodeAcceptedReply builds a MSG_ACCEPTED reply with the given accept_stat.
// body is the already-XDR-encoded data that follows accept_stat in the reply.
// For SUCCESS it is the procedure result; it is nil for most error
// accept_stat values (PROG_UNAVAIL, PROC_UNAVAIL, GARBAGE_ARGS) but is
// non-nil for PROG_MISMATCH, which carries a struct { uint32 low; uint32
// high; } mismatch_info range per RFC 5531 §9.
func encodeAcceptedReply(xid, acceptStat uint32, body []byte) []byte {
out := make([]byte, 24+len(body))
binary.BigEndian.PutUint32(out[0:4], xid)
binary.BigEndian.PutUint32(out[4:8], rpcMsgReply)
binary.BigEndian.PutUint32(out[8:12], rpcMsgAccepted)
// verf: AUTH_NONE, zero-length opaque
binary.BigEndian.PutUint32(out[12:16], rpcAuthNone)
binary.BigEndian.PutUint32(out[16:20], 0)
binary.BigEndian.PutUint32(out[20:24], acceptStat)
copy(out[24:], body)
return out
}