Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
97 changes: 97 additions & 0 deletions dissect/target/helpers/nfs/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from __future__ import annotations

from contextlib import AbstractContextManager
from typing import TYPE_CHECKING, Generic, Iterator, NamedTuple, TypeVar

from dissect.target.helpers.nfs.nfs3 import (
CookieVerf3,
EntryPlus3,
FileAttributes3,
FileHandle3,
Nfs3Stat,
Read3args,
ReadDirPlusParams,
ReadDirPlusProc,
ReadFileProc,
)
from dissect.target.helpers.nfs.serializer import (
Read3ArgsSerializer,
Read3ResultDeserializer,
ReadDirPlusParamsSerializer,
ReadDirPlusResultDeserializer,
)
from dissect.target.helpers.sunrpc.client import AuthScheme
from dissect.target.helpers.sunrpc.client import Client as SunRpcClient

if TYPE_CHECKING:
from types import TracebackType

Check warning on line 27 in dissect/target/helpers/nfs/client.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/client.py#L27

Added line #L27 was not covered by tests

Credentials = TypeVar("Credentials")
Verifier = TypeVar("Verifier")


class ReadFileError(Exception):
pass


class ReadDirResult(NamedTuple):
dir_attributes: FileAttributes3 | None
entries: list[EntryPlus3]


# RdJ Bit annoying that the Credentials and Verifier keep propagating as type parameters of the class.
# Alternatively, we could use type erasure and couple the auth data with the auth serializer,
# and make the auth data in the `CallBody` class opaque.
class Client(AbstractContextManager, Generic[Credentials, Verifier]):
DIR_COUNT = 4096 # See https://datatracker.ietf.org/doc/html/rfc1813#section-3.3.17
MAX_COUNT = 32768
READ_CHUNK_SIZE = 1024 * 1024

def __init__(self, rpc_client: SunRpcClient[Credentials, Verifier]):
self._rpc_client = rpc_client

def __exit__(self, _: type[BaseException] | None, __: BaseException | None, ___: TracebackType | None) -> bool:
self._rpc_client.close()
return False # Reraise exceptions

Check warning on line 55 in dissect/target/helpers/nfs/client.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/client.py#L54-L55

Added lines #L54 - L55 were not covered by tests

@classmethod
def connect(cls, hostname: str, port: int, auth: AuthScheme[Credentials, Verifier], local_port: int) -> Client:
rpc_client = SunRpcClient.connect(hostname, port, auth, local_port)
return Client(rpc_client)

def readdirplus(self, dir: FileHandle3) -> ReadDirResult | Nfs3Stat:
"""Read the contents of a directory, including file attributes"""

entries = list[EntryPlus3]()
cookie = 0
cookieverf = CookieVerf3(b"\x00")

# Multiple calls might be needed to read the entire directory
while True:
params = ReadDirPlusParams(dir, cookie, cookieverf, dir_count=self.DIR_COUNT, max_count=self.MAX_COUNT)
result = self._rpc_client.call(
ReadDirPlusProc, params, ReadDirPlusParamsSerializer(), ReadDirPlusResultDeserializer()
)
if isinstance(result, Nfs3Stat):
return result

Check warning on line 76 in dissect/target/helpers/nfs/client.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/client.py#L76

Added line #L76 was not covered by tests

entries += result.entries
if result.eof or len(result.entries) == 0:
return ReadDirResult(result.dir_attributes, entries)

cookie = result.entries[-1].cookie
cookieverf = result.cookieverf

Check warning on line 83 in dissect/target/helpers/nfs/client.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/client.py#L82-L83

Added lines #L82 - L83 were not covered by tests

def readfile_by_handle(self, handle: FileHandle3) -> Iterator[bytes]:
"""Read a file by its file handle"""
offset = 0
count = self.READ_CHUNK_SIZE
while True:
params = Read3args(handle, offset, count)
result = self._rpc_client.call(ReadFileProc, params, Read3ArgsSerializer(), Read3ResultDeserializer())
if isinstance(result, Nfs3Stat):
raise ReadFileError(f"Failed to read file: {result}")
yield result.data
if result.eof:
return
offset += result.count

Check warning on line 97 in dissect/target/helpers/nfs/client.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/client.py#L87-L97

Added lines #L87 - L97 were not covered by tests
62 changes: 62 additions & 0 deletions dissect/target/helpers/nfs/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import argparse

Check warning on line 1 in dissect/target/helpers/nfs/demo.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/demo.py#L1

Added line #L1 was not covered by tests

from dissect.target.helpers.nfs.client import Client as NfsClient
from dissect.target.helpers.nfs.nfs3 import EntryPlus3, GetPortProc, MountOK, MountProc
from dissect.target.helpers.nfs.serializer import MountResultDeserializer
from dissect.target.helpers.sunrpc.client import Client, auth_null, auth_unix
from dissect.target.helpers.sunrpc.serializer import (

Check warning on line 7 in dissect/target/helpers/nfs/demo.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/demo.py#L3-L7

Added lines #L3 - L7 were not covered by tests
PortMappingSerializer,
StringSerializer,
UInt32Serializer,
)
from dissect.target.helpers.sunrpc.sunrpc import PortMapping, Protocol

Check warning on line 12 in dissect/target/helpers/nfs/demo.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/demo.py#L12

Added line #L12 was not covered by tests

NFS_PROGRAM = 100003
NFS_V3 = 3

Check warning on line 15 in dissect/target/helpers/nfs/demo.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/demo.py#L14-L15

Added lines #L14 - L15 were not covered by tests


# NFS client demo, showing how to connect to an NFS server and list the contents of a directory
# Note: some nfs servers require connecting using a low port number (use --port)
def main():
parser = argparse.ArgumentParser(description="NFS Client")
parser.add_argument("root", type=str, help="The root directory to mount")
parser.add_argument("--hostname", type=str, default="localhost", help="The hostname of the NFS server")
parser.add_argument("--port", type=int, default=0, help="The local port to bind to (default: 0)")
parser.add_argument("--uid", type=int, default=1000, help="The user id to use for authentication")
parser.add_argument("--gid", type=int, default=1000, help="The group id to use for authentication")
parser.add_argument("--index", type=int, default=-1, help="The index of the file to read (starting at 1)")
args = parser.parse_args()

Check warning on line 28 in dissect/target/helpers/nfs/demo.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/demo.py#L20-L28

Added lines #L20 - L28 were not covered by tests

# RdJ: Perhaps move portmapper to nfs client and cache the mapping
with Client.connect_port_mapper(args.hostname) as port_mapper_client:
params_mount = PortMapping(program=MountProc.program, version=MountProc.version, protocol=Protocol.TCP)
mount_port = port_mapper_client.call(GetPortProc, params_mount, PortMappingSerializer(), UInt32Serializer())
params_nfs = PortMapping(program=NFS_PROGRAM, version=NFS_V3, protocol=Protocol.TCP)
nfs_port = port_mapper_client.call(GetPortProc, params_nfs, PortMappingSerializer(), UInt32Serializer())

Check warning on line 35 in dissect/target/helpers/nfs/demo.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/demo.py#L31-L35

Added lines #L31 - L35 were not covered by tests

# RdJ: Encapsualte in dedicated mount client? Or move to nfs client.
with Client.connect(args.hostname, mount_port, auth_null(), args.port) as mount_client:
mount_result = mount_client.call(MountProc, args.root, StringSerializer(), MountResultDeserializer())
if not isinstance(mount_result, MountOK):
print(f"Failed to mount {args.root} with error code {mount_result}")
return

Check warning on line 42 in dissect/target/helpers/nfs/demo.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/demo.py#L38-L42

Added lines #L38 - L42 were not covered by tests

auth = auth_unix("twigtop", args.uid, args.gid, [])
with NfsClient.connect(args.hostname, nfs_port, auth, args.port) as nfs_client:
readdir_result = nfs_client.readdirplus(mount_result.filehandle)
for index, entry in enumerate(readdir_result.entries, start=1):
if entry.attributes:
print(f"{index:<5} {entry.name:<30} {entry.attributes.size:<10}")

Check warning on line 49 in dissect/target/helpers/nfs/demo.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/demo.py#L44-L49

Added lines #L44 - L49 were not covered by tests

if args.index < 0:
return
file_entry: EntryPlus3 = readdir_result.entries[args.index - 1]
if file_entry.attributes:
file_contents = nfs_client.readfile_by_handle(file_entry.handle)
with open(file_entry.name, "wb") as f:
for chunk in file_contents:
f.write(chunk)

Check warning on line 58 in dissect/target/helpers/nfs/demo.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/demo.py#L51-L58

Added lines #L51 - L58 were not covered by tests


if __name__ == "__main__":
main()

Check warning on line 62 in dissect/target/helpers/nfs/demo.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/demo.py#L61-L62

Added lines #L61 - L62 were not covered by tests
169 changes: 169 additions & 0 deletions dissect/target/helpers/nfs/nfs3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
from __future__ import annotations

from dataclasses import dataclass
from enum import IntEnum
from typing import ClassVar, NamedTuple

# See https://datatracker.ietf.org/doc/html/rfc1057


class ProcedureDescriptor(NamedTuple):
program: int
version: int
procedure: int


# List of procedure descriptors
GetPortProc = ProcedureDescriptor(100000, 2, 3)
MountProc = ProcedureDescriptor(100005, 3, 1)
ReadDirPlusProc = ProcedureDescriptor(100003, 3, 17)
ReadFileProc = ProcedureDescriptor(100003, 3, 6)


class Nfs3Stat(IntEnum):
OK = 0
ERR_PERM = 1
ERR_NOENT = 2
ERR_IO = 5
ERR_NXIO = 6
ERR_ACCES = 13
ERR_EXIST = 17
ERR_XDEV = 18
ERR_NODEV = 19
ERR_NOTDIR = 20
ERR_ISDIR = 21
ERR_INVAL = 22
ERR_FBIG = 27
ERR_NOSPC = 28
ERR_ROFS = 30
ERR_MLINK = 31
ERR_NAMETOOLONG = 63
ERR_NOTEMPTY = 66
ERR_DQUOT = 69
ERR_STALE = 70
ERR_REMOTE = 71
ERR_BADHANDLE = 10001


@dataclass
class FileHandle3:
MAXSIZE: ClassVar[int] = 64
opaque: bytes

def __post_init__(self):
if len(self.opaque) > self.MAXSIZE:
raise ValueError(f"FileHandle3 cannot exceed {self.MAXSIZE} bytes")

Check warning on line 55 in dissect/target/helpers/nfs/nfs3.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/nfs3.py#L55

Added line #L55 was not covered by tests


@dataclass
class CookieVerf3:
MAXSIZE: ClassVar[int] = 8
opaque: bytes

def __post_init__(self):
if len(self.opaque) > self.MAXSIZE:
raise ValueError(f"CookieVerf cannot exceed {self.MAXSIZE} bytes")

Check warning on line 65 in dissect/target/helpers/nfs/nfs3.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/helpers/nfs/nfs3.py#L65

Added line #L65 was not covered by tests


class FileType3(IntEnum):
REG = 1 # regular file
DIR = 2 # directory
BLK = 3 # block special
CHR = 4 # character special
LNK = 5 # symbolic link
SOCK = 6 # socket
FIFO = 7 # fifo


@dataclass
class SpecData3:
specdata1: int
specdata2: int


@dataclass
class NfsTime3:
seconds: int
nseconds: int


@dataclass
class FileAttributes3:
type: FileType3
mode: int
nlink: int
uid: int
gid: int
size: int
used: int
rdev: SpecData3
fsid: int
fileid: int
atime: NfsTime3
mtime: NfsTime3
ctime: NfsTime3


class MountStat3(IntEnum):
OK = 0 # no error
ERR_PERM = 1 # Not owner
ERR_NOENT = 2 # No such file or directory
ERR_IO = 5 # I/O error
ERR_ACCES = 13 # Permission denied
ERR_NOTDIR = 20 # Not a directory
ERR_INVAL = 22 # Invalid argument
ERR_NAMETOOLONG = 63 # Filename too long
ERR_NOTSUPP = 10004 # Operation not supported
ERR_SERVERFAULT = 10006 # A failure on the server


@dataclass
class MountParams:
dirpath: str


@dataclass
class MountOK:
filehandle: FileHandle3
auth_flavors: list[int]


@dataclass
class ReadDirPlusParams:
dir: FileHandle3
cookie: int
cookieverf: CookieVerf3
dir_count: int
max_count: int


@dataclass
class EntryPlus3:
fileid: int
name: str
cookie: int
attributes: FileAttributes3 | None
handle: FileHandle3 | None


@dataclass
class ReadDirPlusResult3:
dir_attributes: FileAttributes3 | None
cookieverf: CookieVerf3
entries: list[EntryPlus3]
eof: bool


@dataclass
class Read3args:
file: FileHandle3
offset: int
count: int


@dataclass
class Read3resok:
file_attributes: FileAttributes3 | None
count: int
eof: bool
data: bytes
Loading
Loading