-
Notifications
You must be signed in to change notification settings - Fork 89
POC: Pure Python NFS client #997
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
cebaec2
WIP
twiggler 5d267af
Implement poc nfs
twiggler 3d20651
fix: NamedTuple and multiple inheritance does not work on older versi…
twiggler 062210a
fix: Unsupported | operator for enum and other type oeprand within co…
twiggler d0a3b13
refactor: remove useless check
twiggler 2269b59
refactor: add procedure descriptor to give context to the numbers
twiggler c85f380
refactor: throw exception when message is incomplete
twiggler 151f91e
feature: Add contextmanager to autoclose socket
twiggler 34bf08b
fix: correctly wrap rejected reply
twiggler 85b55be
refactor: move implementation of xdr primitives to serializer / deser…
twiggler 21400b0
chore: fix casing
twiggler f8073fc
fix: typing of deserialized enums.
twiggler cf87280
fix: change assert_never to runtim errors
twiggler 8df1532
refactor: compress Int32Serializer / UInt32Serializer
twiggler 635fc45
fix minor comments
twiggler File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| 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 | ||
|
|
||
| @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 | ||
|
|
||
| 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 | ||
|
|
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import argparse | ||
|
|
||
| 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 ( | ||
| PortMappingSerializer, | ||
| StringSerializer, | ||
| UInt32Serializer, | ||
| ) | ||
| from dissect.target.helpers.sunrpc.sunrpc import PortMapping, Protocol | ||
|
|
||
| NFS_PROGRAM = 100003 | ||
| NFS_V3 = 3 | ||
|
|
||
|
|
||
| # 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() | ||
|
|
||
| # 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()) | ||
|
|
||
| # 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 | ||
|
|
||
| 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}") | ||
|
|
||
| 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) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
|
|
||
|
|
||
| @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") | ||
|
|
||
|
|
||
| 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 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.