mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
* 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)
417 lines
12 KiB
Go
417 lines
12 KiB
Go
package nfs
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"io"
|
|
"net"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func buildRPCCall(t *testing.T, xid, prog, vers, proc uint32, credBody, verfBody, args []byte) []byte {
|
|
t.Helper()
|
|
pad := func(b []byte) []byte {
|
|
r := len(b) % 4
|
|
if r == 0 {
|
|
return b
|
|
}
|
|
out := make([]byte, len(b)+4-r)
|
|
copy(out, b)
|
|
return out
|
|
}
|
|
buf := new(bytes.Buffer)
|
|
write := func(v uint32) {
|
|
var b [4]byte
|
|
binary.BigEndian.PutUint32(b[:], v)
|
|
buf.Write(b[:])
|
|
}
|
|
write(xid)
|
|
write(rpcMsgCall)
|
|
write(2) // rpcvers
|
|
write(prog)
|
|
write(vers)
|
|
write(proc)
|
|
// cred
|
|
write(rpcAuthNone)
|
|
write(uint32(len(credBody)))
|
|
buf.Write(pad(credBody))
|
|
// verf
|
|
write(rpcAuthNone)
|
|
write(uint32(len(verfBody)))
|
|
buf.Write(pad(verfBody))
|
|
buf.Write(args)
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func parseAcceptedReply(t *testing.T, reply []byte) (xid, acceptStat uint32, body []byte) {
|
|
t.Helper()
|
|
if len(reply) < 24 {
|
|
t.Fatalf("reply too short: %d bytes", len(reply))
|
|
}
|
|
xid = binary.BigEndian.Uint32(reply[0:4])
|
|
if mt := binary.BigEndian.Uint32(reply[4:8]); mt != rpcMsgReply {
|
|
t.Fatalf("msg_type=%d, want REPLY", mt)
|
|
}
|
|
if rs := binary.BigEndian.Uint32(reply[8:12]); rs != rpcMsgAccepted {
|
|
t.Fatalf("reply_stat=%d, want ACCEPTED", rs)
|
|
}
|
|
// verf
|
|
verfLen := binary.BigEndian.Uint32(reply[16:20])
|
|
if verfLen != 0 {
|
|
t.Fatalf("unexpected verf length %d", verfLen)
|
|
}
|
|
acceptStat = binary.BigEndian.Uint32(reply[20:24])
|
|
body = reply[24:]
|
|
return
|
|
}
|
|
|
|
func newTestPortmap() *portmapServer {
|
|
return newPortmapServer("127.0.0.1", portmapPort, 2049)
|
|
}
|
|
|
|
func TestParseRPCCall_SkipsAuth(t *testing.T) {
|
|
cred := []byte("hello") // 5 bytes -> padded to 8
|
|
verf := []byte{}
|
|
args := []byte{0x01, 0x02, 0x03, 0x04}
|
|
msg := buildRPCCall(t, 42, portmapProgram, portmapVersion, pmapProcNull, cred, verf, args)
|
|
|
|
xid, prog, vers, proc, gotArgs, err := parseRPCCall(msg)
|
|
if err != nil {
|
|
t.Fatalf("parseRPCCall: %v", err)
|
|
}
|
|
if xid != 42 || prog != portmapProgram || vers != portmapVersion || proc != pmapProcNull {
|
|
t.Fatalf("header mismatch: xid=%d prog=%d vers=%d proc=%d", xid, prog, vers, proc)
|
|
}
|
|
if !bytes.Equal(gotArgs, args) {
|
|
t.Fatalf("args mismatch: got %x want %x", gotArgs, args)
|
|
}
|
|
}
|
|
|
|
func TestParseRPCCall_RejectsReply(t *testing.T) {
|
|
buf := make([]byte, 40)
|
|
binary.BigEndian.PutUint32(buf[4:8], rpcMsgReply)
|
|
if _, _, _, _, _, err := parseRPCCall(buf); err == nil {
|
|
t.Fatal("expected error on reply-typed message")
|
|
}
|
|
}
|
|
|
|
func TestParseRPCCall_TruncatedAuth(t *testing.T) {
|
|
// Claim huge cred length but provide no body.
|
|
buf := make([]byte, 40)
|
|
binary.BigEndian.PutUint32(buf[4:8], rpcMsgCall)
|
|
binary.BigEndian.PutUint32(buf[8:12], 2)
|
|
binary.BigEndian.PutUint32(buf[28:32], 1000) // cred len
|
|
if _, _, _, _, _, err := parseRPCCall(buf); err == nil {
|
|
t.Fatal("expected error on truncated auth")
|
|
}
|
|
}
|
|
|
|
func TestHandleCall_Null(t *testing.T) {
|
|
ps := newTestPortmap()
|
|
msg := buildRPCCall(t, 7, portmapProgram, portmapVersion, pmapProcNull, nil, nil, nil)
|
|
reply := ps.handleCall(msg)
|
|
xid, acc, body := parseAcceptedReply(t, reply)
|
|
if xid != 7 || acc != rpcAcceptSuccess || len(body) != 0 {
|
|
t.Fatalf("null reply xid=%d acc=%d body=%x", xid, acc, body)
|
|
}
|
|
}
|
|
|
|
func TestHandleCall_GetPort_HitAndMiss(t *testing.T) {
|
|
ps := newTestPortmap()
|
|
|
|
buildQuery := func(prog, vers, prot uint32) []byte {
|
|
args := make([]byte, 16)
|
|
binary.BigEndian.PutUint32(args[0:4], prog)
|
|
binary.BigEndian.PutUint32(args[4:8], vers)
|
|
binary.BigEndian.PutUint32(args[8:12], prot)
|
|
// port field is ignored by the server; leave zero
|
|
return args
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
prog, vers, prot uint32
|
|
wantPort uint32
|
|
}{
|
|
{"nfs-v3-tcp-hit", nfsProgram, 3, ipProtoTCP, 2049},
|
|
{"mount-v3-tcp-hit", mountProgram, 3, ipProtoTCP, 2049},
|
|
{"mount-v1-tcp-miss", mountProgram, 1, ipProtoTCP, 0},
|
|
{"nfs-v3-udp-miss", nfsProgram, 3, ipProtoUDP, 0},
|
|
{"nlm-miss", 100021, 4, ipProtoTCP, 0},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
msg := buildRPCCall(t, 11, portmapProgram, portmapVersion, pmapProcGetPort, nil, nil, buildQuery(tc.prog, tc.vers, tc.prot))
|
|
reply := ps.handleCall(msg)
|
|
xid, acc, body := parseAcceptedReply(t, reply)
|
|
if xid != 11 {
|
|
t.Fatalf("xid=%d want 11", xid)
|
|
}
|
|
if acc != rpcAcceptSuccess {
|
|
t.Fatalf("acc=%d want SUCCESS", acc)
|
|
}
|
|
if len(body) != 4 {
|
|
t.Fatalf("getport body len=%d want 4", len(body))
|
|
}
|
|
got := binary.BigEndian.Uint32(body)
|
|
if got != tc.wantPort {
|
|
t.Fatalf("port=%d want %d", got, tc.wantPort)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleCall_Dump(t *testing.T) {
|
|
ps := newTestPortmap()
|
|
msg := buildRPCCall(t, 13, portmapProgram, portmapVersion, pmapProcDump, nil, nil, nil)
|
|
reply := ps.handleCall(msg)
|
|
_, acc, body := parseAcceptedReply(t, reply)
|
|
if acc != rpcAcceptSuccess {
|
|
t.Fatalf("acc=%d", acc)
|
|
}
|
|
var entries []portmapEntry
|
|
p := 0
|
|
for p+4 <= len(body) {
|
|
vf := binary.BigEndian.Uint32(body[p : p+4])
|
|
p += 4
|
|
if vf == 0 {
|
|
break
|
|
}
|
|
if p+16 > len(body) {
|
|
t.Fatalf("truncated entry at %d", p)
|
|
}
|
|
entries = append(entries, portmapEntry{
|
|
Program: binary.BigEndian.Uint32(body[p : p+4]),
|
|
Version: binary.BigEndian.Uint32(body[p+4 : p+8]),
|
|
Protocol: binary.BigEndian.Uint32(body[p+8 : p+12]),
|
|
Port: binary.BigEndian.Uint32(body[p+12 : p+16]),
|
|
})
|
|
p += 16
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Fatalf("got %d dump entries, want 2: %+v", len(entries), entries)
|
|
}
|
|
wantSet := map[portmapEntry]bool{
|
|
{Program: nfsProgram, Version: 3, Protocol: ipProtoTCP, Port: 2049}: false,
|
|
{Program: mountProgram, Version: 3, Protocol: ipProtoTCP, Port: 2049}: false,
|
|
}
|
|
for _, e := range entries {
|
|
if _, ok := wantSet[e]; !ok {
|
|
t.Fatalf("unexpected dump entry %+v", e)
|
|
}
|
|
wantSet[e] = true
|
|
}
|
|
for e, seen := range wantSet {
|
|
if !seen {
|
|
t.Fatalf("missing dump entry %+v", e)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleCall_UnknownProg(t *testing.T) {
|
|
ps := newTestPortmap()
|
|
msg := buildRPCCall(t, 1, 999999, 1, 0, nil, nil, nil)
|
|
reply := ps.handleCall(msg)
|
|
_, acc, _ := parseAcceptedReply(t, reply)
|
|
if acc != rpcAcceptProgUnavail {
|
|
t.Fatalf("acc=%d want PROG_UNAVAIL", acc)
|
|
}
|
|
}
|
|
|
|
func TestHandleCall_VersionMismatch(t *testing.T) {
|
|
ps := newTestPortmap()
|
|
msg := buildRPCCall(t, 1, portmapProgram, 42, pmapProcNull, nil, nil, nil)
|
|
reply := ps.handleCall(msg)
|
|
_, acc, body := parseAcceptedReply(t, reply)
|
|
if acc != rpcAcceptProgMismatch {
|
|
t.Fatalf("acc=%d want PROG_MISMATCH", acc)
|
|
}
|
|
if len(body) != 8 {
|
|
t.Fatalf("mismatch body len=%d want 8", len(body))
|
|
}
|
|
lo := binary.BigEndian.Uint32(body[0:4])
|
|
hi := binary.BigEndian.Uint32(body[4:8])
|
|
if lo != portmapVersion || hi != portmapVersion {
|
|
t.Fatalf("mismatch range lo=%d hi=%d", lo, hi)
|
|
}
|
|
}
|
|
|
|
func TestHandleCall_UnknownProc(t *testing.T) {
|
|
ps := newTestPortmap()
|
|
msg := buildRPCCall(t, 1, portmapProgram, portmapVersion, 42, nil, nil, nil)
|
|
reply := ps.handleCall(msg)
|
|
_, acc, _ := parseAcceptedReply(t, reply)
|
|
if acc != rpcAcceptProcUnavail {
|
|
t.Fatalf("acc=%d want PROC_UNAVAIL", acc)
|
|
}
|
|
}
|
|
|
|
func TestHandleCall_SetRefused(t *testing.T) {
|
|
ps := newTestPortmap()
|
|
args := make([]byte, 16) // mapping struct
|
|
msg := buildRPCCall(t, 1, portmapProgram, portmapVersion, pmapProcSet, nil, nil, args)
|
|
reply := ps.handleCall(msg)
|
|
_, acc, body := parseAcceptedReply(t, reply)
|
|
if acc != rpcAcceptSuccess {
|
|
t.Fatalf("acc=%d", acc)
|
|
}
|
|
if len(body) != 4 || binary.BigEndian.Uint32(body) != 0 {
|
|
t.Fatalf("PMAP_SET must return FALSE, got %x", body)
|
|
}
|
|
}
|
|
|
|
// pickFreePort asks the OS for an unused high port by opening and closing a
|
|
// listener on it. Used so the end-to-end tests can run in parallel without
|
|
// stepping on the privileged default port 111.
|
|
func pickFreePort(t *testing.T) int {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
defer ln.Close()
|
|
return ln.Addr().(*net.TCPAddr).Port
|
|
}
|
|
|
|
func TestPortmapServer_UDPGetPort(t *testing.T) {
|
|
port := pickFreePort(t)
|
|
ps := newPortmapServer("127.0.0.1", port, 2049)
|
|
if err := ps.Start(); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = ps.Close() })
|
|
|
|
args := make([]byte, 16)
|
|
binary.BigEndian.PutUint32(args[0:4], nfsProgram)
|
|
binary.BigEndian.PutUint32(args[4:8], 3)
|
|
binary.BigEndian.PutUint32(args[8:12], ipProtoTCP)
|
|
msg := buildRPCCall(t, 99, portmapProgram, portmapVersion, pmapProcGetPort, nil, nil, args)
|
|
|
|
conn, err := net.Dial("udp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
|
if err != nil {
|
|
t.Fatalf("dial udp: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
|
if _, err := conn.Write(msg); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
buf := make([]byte, 4096)
|
|
n, err := conn.Read(buf)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
xid, acc, body := parseAcceptedReply(t, buf[:n])
|
|
if xid != 99 || acc != rpcAcceptSuccess || len(body) != 4 {
|
|
t.Fatalf("bad reply xid=%d acc=%d body=%x", xid, acc, body)
|
|
}
|
|
if got := binary.BigEndian.Uint32(body); got != 2049 {
|
|
t.Fatalf("udp getport port=%d want 2049", got)
|
|
}
|
|
}
|
|
|
|
func TestPortmapServer_CloseEvictsIdleTCPConn(t *testing.T) {
|
|
port := pickFreePort(t)
|
|
ps := newPortmapServer("127.0.0.1", port, 2049)
|
|
if err := ps.Start(); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
|
|
conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
|
if err != nil {
|
|
_ = ps.Close()
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Issue one call and read its reply so the server-side connection is
|
|
// definitely registered before we trigger shutdown.
|
|
msg := buildRPCCall(t, 1, portmapProgram, portmapVersion, pmapProcNull, nil, nil, nil)
|
|
var mark [4]byte
|
|
binary.BigEndian.PutUint32(mark[:], uint32(len(msg))|(1<<31))
|
|
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
|
if _, err := conn.Write(mark[:]); err != nil {
|
|
t.Fatalf("write mark: %v", err)
|
|
}
|
|
if _, err := conn.Write(msg); err != nil {
|
|
t.Fatalf("write msg: %v", err)
|
|
}
|
|
if _, err := io.ReadFull(conn, mark[:]); err != nil {
|
|
t.Fatalf("read mark: %v", err)
|
|
}
|
|
rlen := binary.BigEndian.Uint32(mark[:]) &^ (1 << 31)
|
|
if _, err := io.ReadFull(conn, make([]byte, rlen)); err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
|
|
// Close must return long before the TCP idle deadline (30s) — in
|
|
// other words, the server must actively close the idle conn rather
|
|
// than wait for the deadline or for the client to disconnect.
|
|
done := make(chan error, 1)
|
|
go func() { done <- ps.Close() }()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Close did not return within 2s; in-flight conn not evicted")
|
|
}
|
|
|
|
_ = conn.SetReadDeadline(time.Now().Add(1 * time.Second))
|
|
if _, err := conn.Read(make([]byte, 4)); err == nil {
|
|
t.Fatal("expected read error on client conn after server Close")
|
|
}
|
|
}
|
|
|
|
func TestPortmapServer_TCPGetPort(t *testing.T) {
|
|
port := pickFreePort(t)
|
|
ps := newPortmapServer("127.0.0.1", port, 2049)
|
|
if err := ps.Start(); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = ps.Close() })
|
|
|
|
args := make([]byte, 16)
|
|
binary.BigEndian.PutUint32(args[0:4], mountProgram)
|
|
binary.BigEndian.PutUint32(args[4:8], 3)
|
|
binary.BigEndian.PutUint32(args[8:12], ipProtoTCP)
|
|
msg := buildRPCCall(t, 123, portmapProgram, portmapVersion, pmapProcGetPort, nil, nil, args)
|
|
|
|
conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
|
if err != nil {
|
|
t.Fatalf("dial tcp: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
|
|
|
// record mark: last-fragment bit + length
|
|
var mark [4]byte
|
|
binary.BigEndian.PutUint32(mark[:], uint32(len(msg))|(1<<31))
|
|
if _, err := conn.Write(mark[:]); err != nil {
|
|
t.Fatalf("write mark: %v", err)
|
|
}
|
|
if _, err := conn.Write(msg); err != nil {
|
|
t.Fatalf("write msg: %v", err)
|
|
}
|
|
|
|
var rmark [4]byte
|
|
if _, err := io.ReadFull(conn, rmark[:]); err != nil {
|
|
t.Fatalf("read mark: %v", err)
|
|
}
|
|
rlen := binary.BigEndian.Uint32(rmark[:]) &^ (1 << 31)
|
|
buf := make([]byte, rlen)
|
|
if _, err := io.ReadFull(conn, buf); err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
xid, acc, body := parseAcceptedReply(t, buf)
|
|
if xid != 123 || acc != rpcAcceptSuccess || len(body) != 4 {
|
|
t.Fatalf("bad reply xid=%d acc=%d body=%x", xid, acc, body)
|
|
}
|
|
if got := binary.BigEndian.Uint32(body); got != 2049 {
|
|
t.Fatalf("tcp getport port=%d want 2049", got)
|
|
}
|
|
}
|