Files
seaweedfs/weed/server/nfs/server.go
T
Chris LuandGitHub 3d39324bc1 fix(nfs): make Linux mount -t nfs work without client workaround (#9199) (#9201)
* fix(nfs): make Linux `mount -t nfs` work without client-side workaround (#9199)

The upstream go-nfs library serves NFSv3 + MOUNT on a single TCP port and
does not register with portmap. Linux mount.nfs queries portmap on port 111
first, so the plain `mount -t nfs host:/export /mnt` form failed with
"portmap query failed" / "requested NFS version or transport protocol is
not supported" against a default `weed nfs` deployment.

- Add a minimal PORTMAP v2 responder (weed/server/nfs/portmap.go) with
  TCP+UDP listeners implementing PMAP_NULL, PMAP_GETPORT, PMAP_DUMP, and
  proper PROG_MISMATCH / PROG_UNAVAIL / PROC_UNAVAIL responses.
  Advertises NFS v3 TCP and MOUNT v3 TCP at the configured NFS port.

- New CLI flag `-portmap.bind` (empty, disabled by default) to opt into
  the responder. Binding port 111 requires root or CAP_NET_BIND_SERVICE
  and must not collide with a system rpcbind.

- Extended `weed nfs -h` help with the two supported ways to mount from
  Linux (client-side portmap bypass, or server-side `-portmap.bind`).

- Startup log now prints a copy-pasteable mount command tailored to
  whether portmap is enabled.

Unit tests cover RPC/XDR parsing, accept-stat paths, and a TCP+UDP
round-trip against the real listener.

Verified in a privileged Debian 12 container: with `-portmap.bind=0.0.0.0`
the exact command from #9199 (`mount -t nfs -o nfsvers=3,nolock
host:/export /mnt`) now succeeds and both read and write work.

* fix(nfs): harden portmap responder per review feedback (#9201)

Addresses three review findings on the portmap responder:

- parseRPCCall: validate opaque_auth length against the record limit
  before applying the XDR 4-byte padding, so a near-uint32-max authLen
  can no longer overflow (authLen + 3) and bypass the bounds check.
  (gemini-code-assist)

- serveTCP/Close: track live TCP connections and evict them on Close()
  so shutdown does not block on idle clients waiting for the read
  deadline to trip. serveTCP also no longer tears the listener down on
  a non-fatal Accept error (e.g. EMFILE); it logs and retries after a
  small back-off. Replaces the atomic.Bool closed flag with a
  mutex-guarded one so closed, conns, and the shutdown transition stay
  consistent. (coderabbit, minor)

- handleTCPConn: apply per-IO read/write deadlines (30s idle, 10s
  in-flight) so a peer that opens the privileged port 111 and stalls
  cannot pin a goroutine indefinitely. (coderabbit, major)

Adds TestPortmapServer_CloseEvictsIdleTCPConn, which holds a TCP
connection idle and asserts Close() returns within 2s (well under the
30s idle deadline) and that the client sees the eviction.

All existing tests still pass, including under -race.

* fix(nfs): keep portmap UDP responder alive on transient read errors (#9201)

- serveUDP: on a non-shutdown ReadFromUDP error, log, back off, and
  continue instead of returning. Matches how serveTCP now treats
  non-fatal Accept errors so a transient network blip doesn't take
  UDP portmap down until restart. (coderabbit)

- Rename portmapAcceptBackoff -> portmapRetryBackoff now that both
  paths use it.

- pmapProcDump: fix the pre-allocation capacity to match the actual
  encoding (20 bytes per entry + 4-byte terminator), replacing the
  old over-estimate of 24 per entry. No behavior change; just
  documents intent. (coderabbit nit)

* docs(nfs): clarify encodeAcceptedReply body semantics (#9201)

The prior comment said body is "nil when the accept_stat is itself an
error", which was misleading: the PROG_MISMATCH branch already passes
an 8-byte mismatch_info body. Rewrite to enumerate which error
accept_stat values omit the body and call out PROG_MISMATCH as the
exception, referencing RFC 5531 §9. Comment-only. (coderabbit nit)

* fix(nfs): make portmap retry backoff interruptible by Close() (#9201)

serveTCP and serveUDP both sleep portmapRetryBackoff (50ms) after a
non-fatal listener error. If Close() races in during that sleep, the
goroutine can't be interrupted, so Close() has to wait out the
remaining backoff before wg.Wait() returns.

Add a done channel that Close() closes once, and replace both
time.Sleep calls with a select on ps.done + time.After. The window
was tiny in practice but the select makes shutdown strictly bounded
by Close()'s own work. (coderabbit nit)
2026-04-23 13:53:53 -07:00

215 lines
6.6 KiB
Go

package nfs
import (
"context"
"errors"
"fmt"
"net"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
gonfs "github.com/willscott/go-nfs"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type Option struct {
Filer pb.ServerAddress
BindIp string
Port int
FilerRootPath string
ReadOnly bool
AllowedClients []string
VolumeServerAccess string
GrpcDialOption grpc.DialOption
// PortmapBind, when non-empty, enables a built-in portmap v2 responder
// on <PortmapBind>:111 advertising the NFS v3 and MOUNT v3 services at
// Port. Empty (the default) disables portmap; clients must then bypass
// portmap with mount -o port=,mountport=,proto=tcp,mountproto=tcp.
PortmapBind string
}
type Server struct {
option *Option
exportRoot util.FullPath
exportID uint32
signature int32
handleLimit int
clientAuthorizer *clientAuthorizer
sharedReaderCache *filer.ReaderCache
chunkInvalidator chunkInvalidator
filerClient *wdclient.FilerClient
newUploader func() (chunkUploader, error)
withFilerClient filerClientExecutor
withInternalClient internalClientExecutor
}
func NewServer(option *Option) (*Server, error) {
if option == nil {
return nil, errors.New("nfs option is required")
}
if option.Port <= 0 {
return nil, fmt.Errorf("nfs port must be positive: %d", option.Port)
}
if option.FilerRootPath == "" {
option.FilerRootPath = "/"
}
if option.VolumeServerAccess == "" {
option.VolumeServerAccess = "direct"
}
if option.GrpcDialOption == nil {
option.GrpcDialOption = grpc.WithTransportCredentials(insecure.NewCredentials())
}
clientAuthorizer, err := newClientAuthorizer(option.AllowedClients)
if err != nil {
return nil, err
}
var filerClient *wdclient.FilerClient
if option.VolumeServerAccess != "filerProxy" {
var opts *wdclient.FilerClientOption
if option.VolumeServerAccess == "publicUrl" {
opts = &wdclient.FilerClientOption{UrlPreference: wdclient.PreferPublicUrl}
}
filerClient = wdclient.NewFilerClient([]pb.ServerAddress{option.Filer}, option.GrpcDialOption, "", opts)
}
exportRoot := normalizeExportRoot(util.FullPath(option.FilerRootPath))
signature := util.RandomInt32()
return &Server{
option: option,
exportRoot: exportRoot,
exportID: exportIDForRoot(exportRoot),
signature: signature,
handleLimit: 1 << 20,
clientAuthorizer: clientAuthorizer,
filerClient: filerClient,
newUploader: newChunkUploader,
withFilerClient: newFilerClientExecutor(option, signature),
withInternalClient: newInternalClientExecutor(option, signature),
}, nil
}
func (s *Server) Start() error {
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.option.BindIp, s.option.Port))
if err != nil {
return fmt.Errorf("listen nfs on %s:%d: %w", s.option.BindIp, s.option.Port, err)
}
var portmap *portmapServer
if s.option.PortmapBind != "" {
portmap = newPortmapServer(s.option.PortmapBind, portmapPort, uint32(s.option.Port))
if pmErr := portmap.Start(); pmErr != nil {
_ = listener.Close()
return fmt.Errorf("start portmap: %w", pmErr)
}
glog.V(0).Infof("NFS portmap responder listening on %s:%d (NFS v3 tcp=%d, MOUNT v3 tcp=%d)",
s.option.PortmapBind, portmapPort, s.option.Port, s.option.Port)
defer func() {
if portmap != nil {
_ = portmap.Close()
}
}()
}
s.logMountHint()
return s.serve(listener)
}
// logMountHint prints a copy-pasteable Linux mount command so operators can
// see at startup how to mount the export from a client. The go-nfs library
// does not run portmap, so without -portmap.bind the client must bypass
// portmap via -o port=,mountport=,proto=tcp,mountproto=tcp.
func (s *Server) logMountHint() {
exportPath := string(s.exportRoot)
if s.option.PortmapBind != "" {
glog.V(0).Infof("mount example: mount -t nfs -o nfsvers=3,nolock <host>:%s <mountpoint>", exportPath)
return
}
glog.V(0).Infof("mount example (bypasses portmap): mount -t nfs -o nfsvers=3,nolock,noacl,port=%d,mountport=%d,proto=tcp,mountproto=tcp <host>:%s <mountpoint>",
s.option.Port, s.option.Port, exportPath)
glog.V(0).Infof("tip: pass -portmap.bind to enable the built-in portmap responder on port 111 so plain `mount -t nfs host:%s /mnt` works.", exportPath)
}
func (s *Server) serve(listener net.Listener) error {
if s.filerClient != nil {
defer s.filerClient.Close()
}
if s.clientAuthorizer != nil && s.clientAuthorizer.enabled {
listener = &allowlistListener{
Listener: listener,
authorizer: s.clientAuthorizer,
}
}
handler, err := s.newHandler()
if err != nil {
_ = listener.Close()
return err
}
followCtx, followCancel := context.WithCancel(context.Background())
defer followCancel()
followDone := make(chan struct{})
go func() {
defer close(followDone)
s.runMetadataInvalidationLoop(followCtx)
}()
defer func() {
followCancel()
<-followDone
}()
glog.V(0).Infof("Start Seaweed NFS Server filer=%s bind=%s export=%s exportId=%d readOnly=%t allowedClients=%d volumeServerAccess=%s",
s.option.Filer,
listener.Addr(),
s.exportRoot,
s.exportID,
s.option.ReadOnly,
len(s.option.AllowedClients),
s.option.VolumeServerAccess,
)
return gonfs.Serve(listener, handler)
}
func (s *Server) newHandler() (*Handler, error) {
if s == nil {
return nil, errors.New("nfs server is not configured")
}
rootFS := newSeaweedFileSystem(s, s.exportRoot, s.sharedReaderCache)
if s.sharedReaderCache == nil {
s.sharedReaderCache = rootFS.readerCache
}
if s.chunkInvalidator == nil {
s.chunkInvalidator = s.sharedReaderCache
}
return &Handler{
server: s,
rootFS: rootFS,
}, nil
}
func (s *Server) WithFilerClient(streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error {
if s == nil || s.withFilerClient == nil {
return errors.New("nfs filer client is not configured")
}
return s.withFilerClient(streamingMode, fn)
}
func (s *Server) LookupFn() wdclient.LookupFileIdFunctionType {
if s == nil {
return nil
}
if s.option != nil && s.option.VolumeServerAccess == "filerProxy" {
return func(ctx context.Context, fileID string) ([]string, error) {
return []string{fmt.Sprintf("http://%s/?proxyChunkId=%s", s.option.Filer.ToHttpAddress(), fileID)}, nil
}
}
if s.filerClient != nil {
return s.filerClient.GetLookupFileIdFunction()
}
return nil
}