Skip to content

Commit 6fb0e5a

Browse files
authored
POC: Pure Python NFS client (#997)
1 parent a0a8c3e commit 6fb0e5a

File tree

10 files changed

+1267
-0
lines changed

10 files changed

+1267
-0
lines changed

dissect/target/helpers/nfs/__init__.py

Whitespace-only changes.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from __future__ import annotations
2+
3+
from contextlib import AbstractContextManager
4+
from typing import TYPE_CHECKING, Generic, Iterator, NamedTuple, TypeVar
5+
6+
from dissect.target.helpers.nfs.nfs3 import (
7+
CookieVerf3,
8+
EntryPlus3,
9+
FileAttributes3,
10+
FileHandle3,
11+
Nfs3Stat,
12+
Read3args,
13+
ReadDirPlusParams,
14+
ReadDirPlusProc,
15+
ReadFileProc,
16+
)
17+
from dissect.target.helpers.nfs.serializer import (
18+
Read3ArgsSerializer,
19+
Read3ResultDeserializer,
20+
ReadDirPlusParamsSerializer,
21+
ReadDirPlusResultDeserializer,
22+
)
23+
from dissect.target.helpers.sunrpc.client import AuthScheme
24+
from dissect.target.helpers.sunrpc.client import Client as SunRpcClient
25+
26+
if TYPE_CHECKING:
27+
from types import TracebackType
28+
29+
Credentials = TypeVar("Credentials")
30+
Verifier = TypeVar("Verifier")
31+
32+
33+
class ReadFileError(Exception):
34+
pass
35+
36+
37+
class ReadDirResult(NamedTuple):
38+
dir_attributes: FileAttributes3 | None
39+
entries: list[EntryPlus3]
40+
41+
42+
# RdJ Bit annoying that the Credentials and Verifier keep propagating as type parameters of the class.
43+
# Alternatively, we could use type erasure and couple the auth data with the auth serializer,
44+
# and make the auth data in the `CallBody` class opaque.
45+
class Client(AbstractContextManager, Generic[Credentials, Verifier]):
46+
DIR_COUNT = 4096 # See https://datatracker.ietf.org/doc/html/rfc1813#section-3.3.17
47+
MAX_COUNT = 32768
48+
READ_CHUNK_SIZE = 1024 * 1024
49+
50+
def __init__(self, rpc_client: SunRpcClient[Credentials, Verifier]):
51+
self._rpc_client = rpc_client
52+
53+
def __exit__(self, _: type[BaseException] | None, __: BaseException | None, ___: TracebackType | None) -> bool:
54+
self._rpc_client.close()
55+
return False # Reraise exceptions
56+
57+
@classmethod
58+
def connect(cls, hostname: str, port: int, auth: AuthScheme[Credentials, Verifier], local_port: int) -> Client:
59+
rpc_client = SunRpcClient.connect(hostname, port, auth, local_port)
60+
return Client(rpc_client)
61+
62+
def readdirplus(self, dir: FileHandle3) -> ReadDirResult | Nfs3Stat:
63+
"""Read the contents of a directory, including file attributes"""
64+
65+
entries = list[EntryPlus3]()
66+
cookie = 0
67+
cookieverf = CookieVerf3(b"\x00")
68+
69+
# Multiple calls might be needed to read the entire directory
70+
while True:
71+
params = ReadDirPlusParams(dir, cookie, cookieverf, dir_count=self.DIR_COUNT, max_count=self.MAX_COUNT)
72+
result = self._rpc_client.call(
73+
ReadDirPlusProc, params, ReadDirPlusParamsSerializer(), ReadDirPlusResultDeserializer()
74+
)
75+
if isinstance(result, Nfs3Stat):
76+
return result
77+
78+
entries += result.entries
79+
if result.eof or len(result.entries) == 0:
80+
return ReadDirResult(result.dir_attributes, entries)
81+
82+
cookie = result.entries[-1].cookie
83+
cookieverf = result.cookieverf
84+
85+
def readfile_by_handle(self, handle: FileHandle3) -> Iterator[bytes]:
86+
"""Read a file by its file handle"""
87+
offset = 0
88+
count = self.READ_CHUNK_SIZE
89+
while True:
90+
params = Read3args(handle, offset, count)
91+
result = self._rpc_client.call(ReadFileProc, params, Read3ArgsSerializer(), Read3ResultDeserializer())
92+
if isinstance(result, Nfs3Stat):
93+
raise ReadFileError(f"Failed to read file: {result}")
94+
yield result.data
95+
if result.eof:
96+
return
97+
offset += result.count

dissect/target/helpers/nfs/demo.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import argparse
2+
3+
from dissect.target.helpers.nfs.client import Client as NfsClient
4+
from dissect.target.helpers.nfs.nfs3 import EntryPlus3, GetPortProc, MountOK, MountProc
5+
from dissect.target.helpers.nfs.serializer import MountResultDeserializer
6+
from dissect.target.helpers.sunrpc.client import Client, auth_null, auth_unix
7+
from dissect.target.helpers.sunrpc.serializer import (
8+
PortMappingSerializer,
9+
StringSerializer,
10+
UInt32Serializer,
11+
)
12+
from dissect.target.helpers.sunrpc.sunrpc import PortMapping, Protocol
13+
14+
NFS_PROGRAM = 100003
15+
NFS_V3 = 3
16+
17+
18+
# NFS client demo, showing how to connect to an NFS server and list the contents of a directory
19+
# Note: some nfs servers require connecting using a low port number (use --port)
20+
def main():
21+
parser = argparse.ArgumentParser(description="NFS Client")
22+
parser.add_argument("root", type=str, help="The root directory to mount")
23+
parser.add_argument("--hostname", type=str, default="localhost", help="The hostname of the NFS server")
24+
parser.add_argument("--port", type=int, default=0, help="The local port to bind to (default: 0)")
25+
parser.add_argument("--uid", type=int, default=1000, help="The user id to use for authentication")
26+
parser.add_argument("--gid", type=int, default=1000, help="The group id to use for authentication")
27+
parser.add_argument("--index", type=int, default=-1, help="The index of the file to read (starting at 1)")
28+
args = parser.parse_args()
29+
30+
# RdJ: Perhaps move portmapper to nfs client and cache the mapping
31+
with Client.connect_port_mapper(args.hostname) as port_mapper_client:
32+
params_mount = PortMapping(program=MountProc.program, version=MountProc.version, protocol=Protocol.TCP)
33+
mount_port = port_mapper_client.call(GetPortProc, params_mount, PortMappingSerializer(), UInt32Serializer())
34+
params_nfs = PortMapping(program=NFS_PROGRAM, version=NFS_V3, protocol=Protocol.TCP)
35+
nfs_port = port_mapper_client.call(GetPortProc, params_nfs, PortMappingSerializer(), UInt32Serializer())
36+
37+
# RdJ: Encapsualte in dedicated mount client? Or move to nfs client.
38+
with Client.connect(args.hostname, mount_port, auth_null(), args.port) as mount_client:
39+
mount_result = mount_client.call(MountProc, args.root, StringSerializer(), MountResultDeserializer())
40+
if not isinstance(mount_result, MountOK):
41+
print(f"Failed to mount {args.root} with error code {mount_result}")
42+
return
43+
44+
auth = auth_unix("twigtop", args.uid, args.gid, [])
45+
with NfsClient.connect(args.hostname, nfs_port, auth, args.port) as nfs_client:
46+
readdir_result = nfs_client.readdirplus(mount_result.filehandle)
47+
for index, entry in enumerate(readdir_result.entries, start=1):
48+
if entry.attributes:
49+
print(f"{index:<5} {entry.name:<30} {entry.attributes.size:<10}")
50+
51+
if args.index < 0:
52+
return
53+
file_entry: EntryPlus3 = readdir_result.entries[args.index - 1]
54+
if file_entry.attributes:
55+
file_contents = nfs_client.readfile_by_handle(file_entry.handle)
56+
with open(file_entry.name, "wb") as f:
57+
for chunk in file_contents:
58+
f.write(chunk)
59+
60+
61+
if __name__ == "__main__":
62+
main()

dissect/target/helpers/nfs/nfs3.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from enum import IntEnum
5+
from typing import ClassVar, NamedTuple
6+
7+
# See https://datatracker.ietf.org/doc/html/rfc1057
8+
9+
10+
class ProcedureDescriptor(NamedTuple):
11+
program: int
12+
version: int
13+
procedure: int
14+
15+
16+
# List of procedure descriptors
17+
GetPortProc = ProcedureDescriptor(100000, 2, 3)
18+
MountProc = ProcedureDescriptor(100005, 3, 1)
19+
ReadDirPlusProc = ProcedureDescriptor(100003, 3, 17)
20+
ReadFileProc = ProcedureDescriptor(100003, 3, 6)
21+
22+
23+
class Nfs3Stat(IntEnum):
24+
OK = 0
25+
ERR_PERM = 1
26+
ERR_NOENT = 2
27+
ERR_IO = 5
28+
ERR_NXIO = 6
29+
ERR_ACCES = 13
30+
ERR_EXIST = 17
31+
ERR_XDEV = 18
32+
ERR_NODEV = 19
33+
ERR_NOTDIR = 20
34+
ERR_ISDIR = 21
35+
ERR_INVAL = 22
36+
ERR_FBIG = 27
37+
ERR_NOSPC = 28
38+
ERR_ROFS = 30
39+
ERR_MLINK = 31
40+
ERR_NAMETOOLONG = 63
41+
ERR_NOTEMPTY = 66
42+
ERR_DQUOT = 69
43+
ERR_STALE = 70
44+
ERR_REMOTE = 71
45+
ERR_BADHANDLE = 10001
46+
47+
48+
@dataclass
49+
class FileHandle3:
50+
MAXSIZE: ClassVar[int] = 64
51+
opaque: bytes
52+
53+
def __post_init__(self):
54+
if len(self.opaque) > self.MAXSIZE:
55+
raise ValueError(f"FileHandle3 cannot exceed {self.MAXSIZE} bytes")
56+
57+
58+
@dataclass
59+
class CookieVerf3:
60+
MAXSIZE: ClassVar[int] = 8
61+
opaque: bytes
62+
63+
def __post_init__(self):
64+
if len(self.opaque) > self.MAXSIZE:
65+
raise ValueError(f"CookieVerf cannot exceed {self.MAXSIZE} bytes")
66+
67+
68+
class FileType3(IntEnum):
69+
REG = 1 # regular file
70+
DIR = 2 # directory
71+
BLK = 3 # block special
72+
CHR = 4 # character special
73+
LNK = 5 # symbolic link
74+
SOCK = 6 # socket
75+
FIFO = 7 # fifo
76+
77+
78+
@dataclass
79+
class SpecData3:
80+
specdata1: int
81+
specdata2: int
82+
83+
84+
@dataclass
85+
class NfsTime3:
86+
seconds: int
87+
nseconds: int
88+
89+
90+
@dataclass
91+
class FileAttributes3:
92+
type: FileType3
93+
mode: int
94+
nlink: int
95+
uid: int
96+
gid: int
97+
size: int
98+
used: int
99+
rdev: SpecData3
100+
fsid: int
101+
fileid: int
102+
atime: NfsTime3
103+
mtime: NfsTime3
104+
ctime: NfsTime3
105+
106+
107+
class MountStat3(IntEnum):
108+
OK = 0 # no error
109+
ERR_PERM = 1 # Not owner
110+
ERR_NOENT = 2 # No such file or directory
111+
ERR_IO = 5 # I/O error
112+
ERR_ACCES = 13 # Permission denied
113+
ERR_NOTDIR = 20 # Not a directory
114+
ERR_INVAL = 22 # Invalid argument
115+
ERR_NAMETOOLONG = 63 # Filename too long
116+
ERR_NOTSUPP = 10004 # Operation not supported
117+
ERR_SERVERFAULT = 10006 # A failure on the server
118+
119+
120+
@dataclass
121+
class MountParams:
122+
dirpath: str
123+
124+
125+
@dataclass
126+
class MountOK:
127+
filehandle: FileHandle3
128+
auth_flavors: list[int]
129+
130+
131+
@dataclass
132+
class ReadDirPlusParams:
133+
dir: FileHandle3
134+
cookie: int
135+
cookieverf: CookieVerf3
136+
dir_count: int
137+
max_count: int
138+
139+
140+
@dataclass
141+
class EntryPlus3:
142+
fileid: int
143+
name: str
144+
cookie: int
145+
attributes: FileAttributes3 | None
146+
handle: FileHandle3 | None
147+
148+
149+
@dataclass
150+
class ReadDirPlusResult3:
151+
dir_attributes: FileAttributes3 | None
152+
cookieverf: CookieVerf3
153+
entries: list[EntryPlus3]
154+
eof: bool
155+
156+
157+
@dataclass
158+
class Read3args:
159+
file: FileHandle3
160+
offset: int
161+
count: int
162+
163+
164+
@dataclass
165+
class Read3resok:
166+
file_attributes: FileAttributes3 | None
167+
count: int
168+
eof: bool
169+
data: bytes

0 commit comments

Comments
 (0)