remote-fs-browser 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ """Cross-platform remote filesystem browsing."""
2
+ from importlib.metadata import PackageNotFoundError, version
3
+ from .policy import Policy
4
+ from .sessions import Browser, FilesystemSession
5
+
6
+ try:
7
+ __version__ = version('remote-fs-browser')
8
+ except PackageNotFoundError:
9
+ __version__ = '0.0.0'
10
+
11
+ __all__ = ['Browser', 'FilesystemSession', 'Policy', '__version__']
@@ -0,0 +1,8 @@
1
+ """`python -m remote_fs_browser` and frozen builds enter here."""
2
+ import multiprocessing
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == '__main__':
7
+ multiprocessing.freeze_support()
8
+ main()
@@ -0,0 +1,182 @@
1
+ """Backend-neutral read-only operations; network connections belong to one worker."""
2
+ import os
3
+ import stat
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from .policy import normalize
7
+
8
+
9
+ def entry(name, path, mode, size, modified):
10
+ kind = 'directory' if stat.S_ISDIR(mode) else 'file' if stat.S_ISREG(mode) else 'other'
11
+ return {'name': name, 'path': path, 'type': kind,
12
+ 'size': size if kind == 'file' else None,
13
+ 'modified': datetime.fromtimestamp(modified, timezone.utc).isoformat() if modified else None}
14
+
15
+
16
+ def child_path(path, name):
17
+ """Session path of a directory entry, or None when the name cannot be addressed safely."""
18
+ try:
19
+ return normalize(path + '/' + name)
20
+ except ValueError:
21
+ return None
22
+
23
+
24
+ def listing(rows, skipped):
25
+ return {'entries': rows, 'skipped': skipped}
26
+
27
+
28
+ class LocalFilesystem:
29
+ def __init__(self, config):
30
+ self.root = Path(config['root']).resolve(strict=True)
31
+ self.root_fd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY) if os.name != 'nt' else None
32
+
33
+ def _open(self, path, directory=False):
34
+ parts = normalize(path).strip('/').split('/') if normalize(path) != '/' else []
35
+ if os.name != 'nt':
36
+ fd = os.dup(self.root_fd)
37
+ try:
38
+ for i, part in enumerate(parts):
39
+ flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
40
+ if directory or i < len(parts) - 1:
41
+ flags |= os.O_DIRECTORY
42
+ next_fd = os.open(part, flags, dir_fd=fd)
43
+ os.close(fd)
44
+ fd = next_fd
45
+ return fd
46
+ except Exception:
47
+ os.close(fd)
48
+ raise
49
+ target = self.root.joinpath(*parts)
50
+ for parent in [target, *target.parents]:
51
+ if parent == self.root:
52
+ break
53
+ if parent.is_symlink() or (hasattr(parent, 'is_junction') and parent.is_junction()):
54
+ raise PermissionError('Links are not browsable')
55
+ if not target.resolve(strict=True).is_relative_to(self.root):
56
+ raise PermissionError('Path leaves the selected root')
57
+ if directory:
58
+ return str(target)
59
+ fd = os.open(target, os.O_RDONLY | os.O_BINARY)
60
+ try:
61
+ # Validate the actual opened Windows handle, not just its earlier pathname.
62
+ import ctypes as c
63
+ import msvcrt
64
+ from ctypes import wintypes
65
+ fn = c.windll.kernel32.GetFinalPathNameByHandleW
66
+ fn.argtypes = [wintypes.HANDLE, wintypes.LPWSTR, wintypes.DWORD, wintypes.DWORD]
67
+ fn.restype = wintypes.DWORD
68
+ buffer = c.create_unicode_buffer(32768)
69
+ count = fn(msvcrt.get_osfhandle(fd), buffer, len(buffer), 0)
70
+ if not count or count >= len(buffer):
71
+ raise PermissionError('Cannot validate file handle')
72
+ final = buffer.value.removeprefix('\\\\?\\')
73
+ if final.startswith('UNC\\'):
74
+ final = '\\\\' + final[4:]
75
+ if not Path(final).is_relative_to(self.root):
76
+ raise PermissionError('File leaves the selected root')
77
+ return fd
78
+ except Exception:
79
+ os.close(fd)
80
+ raise
81
+
82
+ def list(self, path, limit):
83
+ path = normalize(path)
84
+ handle = self._open(path, directory=True)
85
+ rows, skipped = [], 0
86
+ try:
87
+ with os.scandir(handle) as entries:
88
+ for item in entries:
89
+ if item.is_symlink() or (os.name == 'nt' and getattr(item.stat(follow_symlinks=False), 'st_file_attributes', 0) & 0x400):
90
+ continue
91
+ child = child_path(path, item.name)
92
+ if child is None:
93
+ skipped += 1
94
+ continue
95
+ info = item.stat(follow_symlinks=False)
96
+ rows.append(entry(item.name, child, info.st_mode, info.st_size, info.st_mtime))
97
+ if len(rows) > limit:
98
+ break
99
+ finally:
100
+ if isinstance(handle, int):
101
+ os.close(handle)
102
+ return listing(rows, skipped)
103
+
104
+ def stat(self, path):
105
+ if os.name == 'nt':
106
+ target = self._open(path, directory=True)
107
+ info = os.stat(target, follow_symlinks=False)
108
+ return entry(Path(path).name, normalize(path), info.st_mode, info.st_size, info.st_mtime)
109
+ handle = self._open(path, directory=False)
110
+ try:
111
+ info = os.fstat(handle)
112
+ return entry(Path(path).name, normalize(path), info.st_mode, info.st_size, info.st_mtime)
113
+ finally:
114
+ os.close(handle)
115
+
116
+ def open(self, path):
117
+ fd = self._open(path)
118
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
119
+ os.close(fd)
120
+ raise PermissionError('Only regular files may be read')
121
+ return os.fdopen(fd, 'rb')
122
+
123
+ def close(self):
124
+ if self.root_fd is not None:
125
+ os.close(self.root_fd)
126
+ self.root_fd = None
127
+
128
+
129
+ class SMBFilesystem:
130
+ def __init__(self, config):
131
+ import smbclient
132
+ self.client, self.cache = smbclient, {}
133
+ host = config['host']
134
+ if ':' in host:
135
+ raise ValueError('SMB IPv6 literals are not supported; use an IPv4 address')
136
+ self.root = '\\\\' + host + '\\' + config['share']
137
+ username = config.get('username')
138
+ if config.get('domain') and username and '\\' not in username:
139
+ username = config['domain'] + '\\' + username
140
+ try:
141
+ smbclient.register_session(host, username=username, password=config.get('password'),
142
+ connection_timeout=8, connection_cache=self.cache, auth_protocol='ntlm')
143
+ except Exception:
144
+ self.close()
145
+ raise
146
+
147
+ def _path(self, path):
148
+ path = normalize(path)
149
+ current = self.root
150
+ for part in path.strip('/').split('/') if path != '/' else []:
151
+ current += '\\' + part
152
+ info = self.client.stat(current, follow_symlinks=False, connection_cache=self.cache)
153
+ if stat.S_ISLNK(info.st_mode) or getattr(info, 'st_file_attributes', 0) & 0x400:
154
+ raise PermissionError('Links and reparse points are not browsable')
155
+ return current
156
+
157
+ def list(self, path, limit):
158
+ rows, skipped = [], 0
159
+ for item in self.client.scandir(self._path(path), connection_cache=self.cache):
160
+ info = item.stat(follow_symlinks=False)
161
+ if stat.S_ISLNK(info.st_mode) or getattr(info, 'st_file_attributes', 0) & 0x400:
162
+ continue
163
+ child = child_path(path, item.name)
164
+ if child is None:
165
+ skipped += 1
166
+ continue
167
+ rows.append(entry(item.name, child, info.st_mode, info.st_size, info.st_mtime))
168
+ if len(rows) > limit:
169
+ break
170
+ return listing(rows, skipped)
171
+
172
+ def stat(self, path):
173
+ info = self.client.stat(self._path(path), follow_symlinks=False, connection_cache=self.cache)
174
+ return entry(path.rsplit('/', 1)[-1], normalize(path), info.st_mode, info.st_size, info.st_mtime)
175
+
176
+ def open(self, path):
177
+ if self.stat(path)['type'] != 'file':
178
+ raise PermissionError('Only regular files may be read')
179
+ return self.client.open_file(self._path(path), mode='rb', connection_cache=self.cache)
180
+
181
+ def close(self):
182
+ self.client.reset_connection_cache(connection_cache=self.cache)
@@ -0,0 +1,162 @@
1
+ """Standalone same-port browser and API launcher."""
2
+ import argparse
3
+ import ipaddress
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import secrets
8
+ import sys
9
+
10
+
11
+ def addresses(bind, port):
12
+ import psutil
13
+ import socket
14
+ hosts = [bind] if bind not in ('0.0.0.0', '::') else sorted({
15
+ item.address.split('%')[0]
16
+ for group in psutil.net_if_addrs().values() for item in group
17
+ if item.family in (socket.AF_INET, socket.AF_INET6)
18
+ and not ipaddress.ip_address(item.address.split('%')[0]).is_link_local
19
+ })
20
+ rows = []
21
+ for host in hosts:
22
+ try:
23
+ address = ipaddress.ip_address(host)
24
+ label = 'Local' if address.is_loopback else 'Tailscale/CGNAT' if address.version == 4 and address in ipaddress.ip_network('100.64.0.0/10') else 'Network'
25
+ except ValueError:
26
+ label = 'Network'
27
+ rows.append((label, f'http://[{host}]:{port}' if ':' in host else f'http://{host}:{port}'))
28
+ return rows
29
+
30
+
31
+ def config_home():
32
+ if os.name == 'nt':
33
+ base = os.environ.get('APPDATA') or str(Path.home() / 'AppData' / 'Roaming')
34
+ else:
35
+ base = os.environ.get('XDG_CONFIG_HOME') or str(Path.home() / '.config')
36
+ return Path(base) / 'remotefs'
37
+
38
+
39
+ def load_or_create(path, explicit):
40
+ """Read the configuration; create the default one with a fresh token on first run."""
41
+ from .store import write_private
42
+ path = Path(path)
43
+ if path.exists():
44
+ if os.name != 'nt' and path.stat().st_mode & 0o077:
45
+ print(f'Warning: {path} is readable by other users; restrict it with chmod 600.', file=sys.stderr)
46
+ return json.loads(path.read_text()), False
47
+ if explicit:
48
+ raise SystemExit(f'Configuration file not found: {path}')
49
+ config = {'token': secrets.token_urlsafe(48), 'policy': {}}
50
+ write_private(path, json.dumps(config, indent=2) + '\n')
51
+ return config, True
52
+
53
+
54
+ def build_policy(args, config):
55
+ """Flags beat the config file, which beats auto-detected defaults; lists replace, never merge."""
56
+ from . import defaults
57
+ from .policy import Policy
58
+ values = dict(config.get('policy', {}))
59
+ kinds = {}
60
+ if args.root:
61
+ values['local_roots'] = [str(Path(root).resolve(strict=True)) for root in args.root]
62
+ elif 'local_roots' not in values and not args.no_defaults:
63
+ home = defaults.readable_dirs([defaults.home_root()])
64
+ values['local_roots'] = defaults.readable_dirs([*home, *defaults.mounted_volumes()])
65
+ kinds = {root: 'home' if root in home else 'volume' for root in values['local_roots']}
66
+ if args.allow_network:
67
+ values['network_ranges'] = list(args.allow_network)
68
+ elif 'network_ranges' not in values and not args.no_defaults:
69
+ values['network_ranges'] = defaults.local_subnets()
70
+ return Policy(**values), kinds
71
+
72
+
73
+ def bundled_library():
74
+ """Frozen Windows builds ship libnfs.dll beside the executable."""
75
+ if not getattr(sys, 'frozen', False):
76
+ return None
77
+ for folder in (Path(sys.executable).parent, Path(getattr(sys, '_MEIPASS', ''))):
78
+ candidate = folder / 'libnfs.dll'
79
+ if candidate.is_file():
80
+ return str(candidate)
81
+ return None
82
+
83
+
84
+ def main(argv=None):
85
+ from . import __version__
86
+ parser = argparse.ArgumentParser(prog='remotefs', description='Serve a read-only filesystem browser and API on one port.')
87
+ parser.add_argument('command', nargs='?', choices=['serve'], default='serve')
88
+ parser.add_argument('--version', action='version', version=f'remotefs {__version__}')
89
+ parser.add_argument('--config', help='Private JSON configuration; defaults to the per-user config, created on first run')
90
+ parser.add_argument('--bind', help='Listen address; defaults to 127.0.0.1. Any other address is reachable from the network')
91
+ parser.add_argument('--port', type=int, help='Listen port; defaults to 8080')
92
+ parser.add_argument('--root', action='append', help='Allowed local root; repeat for multiple roots (default: home and mounted volumes)')
93
+ parser.add_argument('--allow-network', action='append', help='Allowed SMB/NFS CIDR; repeat as needed (default: this host\'s private subnets)')
94
+ parser.add_argument('--no-defaults', action='store_true', help='Do not auto-detect roots or networks; expose only what config and flags name')
95
+ parser.add_argument('--print-token', action='store_true', help='Print the service token and exit')
96
+ parser.add_argument('--resolve-host', help=argparse.SUPPRESS)
97
+ args = parser.parse_args(argv)
98
+
99
+ if args.resolve_host:
100
+ import socket
101
+ print(json.dumps(socket.gethostbyaddr(args.resolve_host)[0]))
102
+ return
103
+
104
+ library = bundled_library()
105
+ if library:
106
+ os.environ.setdefault('LIBNFS_LIBRARY', library)
107
+ path = Path(args.config) if args.config else config_home() / 'config.json'
108
+ config, created = load_or_create(path, explicit=bool(args.config))
109
+ token = config.get('token')
110
+ ephemeral = not token
111
+ if ephemeral:
112
+ token = secrets.token_urlsafe(32)
113
+ if args.print_token:
114
+ print(token)
115
+ return
116
+
117
+ from .http import create_app
118
+ from .store import SavedLocations, StoreLocked
119
+ import uvicorn
120
+ policy, kinds = build_policy(args, config)
121
+ saved, saved_note = None, ''
122
+ if not ephemeral:
123
+ try:
124
+ saved = SavedLocations(path.with_name('saved.json'), token)
125
+ except StoreLocked:
126
+ saved_note = f'{path.with_name("saved.json")} was saved under a different token; remembering is off until it is removed'
127
+ else:
128
+ saved_note = 'temporary token; locations are not remembered'
129
+ app = create_app(policy, token=token, root_kinds=kinds, saved_locations=saved)
130
+ bind = args.bind or config.get('bind', '127.0.0.1')
131
+ port = args.port if args.port is not None else config.get('port', 8080)
132
+ if not 1 <= port <= 65535:
133
+ parser.error('Port must be between 1 and 65535')
134
+ try:
135
+ remote = not ipaddress.ip_address(bind).is_loopback
136
+ except ValueError:
137
+ remote = bind not in ('localhost',)
138
+
139
+ show_token = created or ephemeral or sys.stdout.isatty()
140
+ lines = [f'remotefs {__version__} — read-only', '']
141
+ lines += [f'{label:16} {url}/' for label, url in addresses(bind, port)]
142
+ lines += ['', f'{"Config":16} {path}{" (created)" if created else ""}']
143
+ if ephemeral:
144
+ lines.append(f'{"Token":16} {token} (temporary; add a token to the config to keep it)')
145
+ elif show_token:
146
+ lines.append(f'{"Token":16} {token}')
147
+ else:
148
+ lines.append(f'{"Token":16} stored in the config; run remotefs --print-token to show it')
149
+ roots = [f'{root} ({kinds[root]})' if root in kinds else root for root in policy.local_roots]
150
+ lines.append(f'{"Roots":16} {", ".join(roots) or "none"}')
151
+ lines.append(f'{"Networks":16} {", ".join(policy.network_ranges) or "none (SMB/NFS disabled)"}')
152
+ lines.append(f'{"Access":16} read-only: {", ".join(policy.operations)}')
153
+ lines.append(f'{"Remembered":16} {saved_note or f"{len(saved.records)} saved location(s) in {saved.path}"}')
154
+ from .discovery import share_enumeration_available
155
+ if not share_enumeration_available():
156
+ lines.append(f'{"SMB shares":16} enumeration unavailable (impacket not installed); shares can still be entered by name')
157
+ lines.append('')
158
+ if remote:
159
+ lines.append('WARNING: reachable from the network; anyone with the token can read every root above.')
160
+ lines.append('HTTP is unencrypted. Use a trusted network or an encrypted tunnel.')
161
+ print('\n'.join(lines) + '\n', flush=True)
162
+ uvicorn.run(app, host=bind, port=port, access_log=False)
@@ -0,0 +1,91 @@
1
+ """Zero-configuration defaults: what this machine can see, bounded for safe discovery."""
2
+ import ipaddress
3
+ import os
4
+ import socket
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ REAL_FILESYSTEMS = {'ext2', 'ext3', 'ext4', 'xfs', 'btrfs', 'zfs', 'f2fs', 'jfs', 'vfat', 'exfat', 'ntfs', 'ntfs3',
9
+ 'fuseblk', 'cifs', 'smb3', 'nfs', 'nfs4', 'hfsplus', 'apfs'}
10
+ LINUX_PREFIXES = ('/mnt/', '/media/', '/run/media/', '/srv/', '/data/', '/home/')
11
+ RFC1918 = [ipaddress.ip_network(n) for n in ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16')]
12
+ # Container, VM, tunnel and link-layer helper interfaces: their subnets are not the LAN.
13
+ VIRTUAL_INTERFACES = ('docker', 'br-', 'veth', 'virbr', 'vmnet', 'vboxnet', 'utun', 'tun', 'tap', 'feth', 'bridge',
14
+ 'llw', 'awdl', 'anpi', 'lo', 'zt', 'tailscale', 'wg', 'lxc', 'lxd', 'cni', 'flannel', 'cali', 'kube')
15
+
16
+
17
+ def home_root():
18
+ return str(Path.home())
19
+
20
+
21
+ def mounted_volumes(platform=None, partitions=None, volumes='/Volumes'):
22
+ """Mounted storage a person would expect under "This Computer", excluding system volumes."""
23
+ platform = platform or sys.platform
24
+ if platform == 'darwin':
25
+ try:
26
+ entries = list(os.scandir(volumes))
27
+ except OSError:
28
+ return []
29
+ # The boot volume appears as a symlink to / and is skipped with every other link.
30
+ return sorted(item.path for item in entries if item.is_dir(follow_symlinks=False) and not item.name.startswith('.'))
31
+ if partitions is None:
32
+ import psutil
33
+ partitions = psutil.disk_partitions(all=False)
34
+ rows = set()
35
+ for part in partitions:
36
+ mount = part.mountpoint
37
+ if platform.startswith('win'):
38
+ if not part.fstype or 'cdrom' in part.opts.split(','):
39
+ continue
40
+ rows.add(mount)
41
+ continue
42
+ if part.fstype not in REAL_FILESYSTEMS or part.device.startswith('/dev/loop'):
43
+ continue
44
+ if mount.startswith(('/boot', '/var/lib', '/snap')) or not (mount + '/').startswith(LINUX_PREFIXES):
45
+ continue
46
+ rows.add(mount)
47
+ return sorted(rows)
48
+
49
+
50
+ def local_subnets(max_prefix=24, addresses=None):
51
+ """RFC1918 IPv4 networks on this host's physical interfaces, narrowed to at most a /24 each.
52
+
53
+ `addresses` rows are (interface, address, netmask); a two-item (address, netmask) row is accepted too.
54
+ """
55
+ if addresses is None:
56
+ import psutil
57
+ addresses = [(name, item.address, item.netmask) for name, group in psutil.net_if_addrs().items() for item in group
58
+ if item.family == socket.AF_INET and item.netmask]
59
+ networks = set()
60
+ for row in addresses:
61
+ name, address, netmask = row if len(row) == 3 else ('', *row)
62
+ if name.lower().startswith(VIRTUAL_INTERFACES):
63
+ continue
64
+ try:
65
+ ip = ipaddress.ip_address(address)
66
+ network = ipaddress.ip_network(f'{address}/{netmask}', strict=False)
67
+ except ValueError:
68
+ continue
69
+ if ip.version != 4 or not any(ip in block for block in RFC1918):
70
+ continue
71
+ if network.prefixlen >= 32:
72
+ continue
73
+ if network.prefixlen < max_prefix:
74
+ network = ipaddress.ip_network(f'{address}/{max_prefix}', strict=False)
75
+ networks.add(network)
76
+ return [str(network) for network in sorted(networks)]
77
+
78
+
79
+ def readable_dirs(paths):
80
+ """Existing, readable directories, resolved and deduplicated, in the order given."""
81
+ rows = []
82
+ for path in paths:
83
+ try:
84
+ resolved = Path(path).resolve(strict=True)
85
+ except OSError:
86
+ continue
87
+ if not resolved.is_dir() or not os.access(resolved, os.R_OK | os.X_OK):
88
+ continue
89
+ if str(resolved) not in rows:
90
+ rows.append(str(resolved))
91
+ return rows
@@ -0,0 +1,150 @@
1
+ """Bounded, opt-in TCP discovery plus protocol-native share/export enumeration."""
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ import ctypes as c
4
+ import ipaddress
5
+ from pathlib import PurePath
6
+ import socket
7
+ from .policy import Policy
8
+ from .hostnames import dns_name, netbios_name
9
+
10
+ # Candidate addresses probed per scan request: four /24 ranges, about half a minute worst case on 64 threads.
11
+ SCAN_BUDGET = 1024
12
+
13
+
14
+ def grouped(policy: Policy, roots, hosts, scanned):
15
+ """The picker's tree: This Computer, then SMB and NFS servers seen from this host."""
16
+ local = [{'type': 'local', 'root': row['root'], 'kind': row['kind'],
17
+ 'label': 'Home' if row['kind'] == 'home' else PurePath(row['root']).name or row['root']} for row in roots]
18
+ hint = None
19
+ if not scanned:
20
+ hint = ('Press Discover to scan ' + ', '.join(policy.network_ranges) + ', or enter a server above.'
21
+ if policy.network_ranges else 'No networks are permitted for SMB/NFS on this service.')
22
+ groups = [{'id': 'local', 'label': 'This Computer', 'items': local}]
23
+ for protocol, label in (('smb', 'SMB'), ('nfs', 'NFS')):
24
+ items = [{'type': protocol, 'host': row['host'], 'label': row.get('name') or row['host'],
25
+ **{key: row[key] for key in ('dns_name', 'netbios_name') if row.get(key)}}
26
+ for row in hosts if protocol in row['protocols']]
27
+ groups.append({'id': protocol, 'label': label, 'items': items, 'hint': hint})
28
+ return groups
29
+
30
+
31
+ def discover(policy: Policy, scan=False, root_kinds=None):
32
+ policy.require('discover')
33
+ kinds = root_kinds or {}
34
+ result = {'roots': [{'type': 'local', 'root': root, 'kind': kinds.get(root, 'configured')} for root in policy.local_roots],
35
+ 'hosts': [], 'notes': ['Automatic discovery is best effort. A hostname/IP can always be supplied within policy.']}
36
+ if not scan:
37
+ result['groups'] = grouped(policy, result['roots'], [], False)
38
+ return result
39
+ hosts = set()
40
+ for network in policy.network_ranges:
41
+ network = ipaddress.ip_network(network)
42
+ if network.num_addresses > 256:
43
+ result['notes'].append('Skipped a range larger than 256 addresses; configure narrower discovery ranges.')
44
+ continue
45
+ hosts.update(str(host) for host in network.hosts())
46
+ if policy.servers:
47
+ hosts &= {policy.host(host) for host in policy.servers}
48
+ hosts = sorted(hosts, key=ipaddress.ip_address)
49
+ if len(hosts) > SCAN_BUDGET:
50
+ result['notes'].append(f'Scanned the first {SCAN_BUDGET} of {len(hosts)} candidate addresses; narrow the permitted ranges to scan the rest.')
51
+ hosts = hosts[:SCAN_BUDGET]
52
+
53
+ def probe(host):
54
+ protocols = []
55
+ for port, name in [(445, 'smb'), (2049, 'nfs')]:
56
+ try:
57
+ # One second covers ARP resolution on Wi-Fi clients; a quarter second missed live LAN hosts.
58
+ with socket.create_connection((host, port), timeout=1.0):
59
+ protocols.append(name)
60
+ except OSError:
61
+ pass
62
+ if not protocols:
63
+ return None
64
+ names = {key: value for key, value in (
65
+ ('dns_name', dns_name(host)), ('netbios_name', netbios_name(host))) if value}
66
+ name = names.get('dns_name') or names.get('netbios_name')
67
+ return {'host': host, 'protocols': protocols, **names, **({'name': name} if name else {})}
68
+
69
+ with ThreadPoolExecutor(max_workers=64) as executor:
70
+ result['hosts'] = [row for row in executor.map(probe, hosts) if row]
71
+ result['groups'] = grouped(policy, result['roots'], result['hosts'], True)
72
+ return result
73
+
74
+
75
+ def share_enumeration_available():
76
+ import importlib.util
77
+ return importlib.util.find_spec('impacket') is not None
78
+
79
+
80
+ def smb_shares(host, credentials):
81
+ if not share_enumeration_available():
82
+ raise RuntimeError('SMB share enumeration needs the impacket package (pip install "remote-fs-browser[smb-enum]"); enter the share name instead')
83
+ from impacket.smbconnection import SMBConnection
84
+ from impacket.smb3structs import SMB2_DIALECT_21
85
+ # Explicit SMB2 dialect prevents falling back to SMB1 browser services.
86
+ connection = SMBConnection(host, host, sess_port=445, timeout=5, preferredDialect=SMB2_DIALECT_21)
87
+ try:
88
+ connection.login(credentials.get('username', ''), credentials.get('password', ''), credentials.get('domain', ''))
89
+ from impacket.dcerpc.v5 import transport, srvs
90
+ rpc = transport.SMBTransport(host, host, filename=r'\srvsvc', smb_connection=connection).get_dce_rpc()
91
+ rows, resume, truncated = [], 0, False
92
+ try:
93
+ rpc.connect()
94
+ rpc.bind(srvs.MSRPC_UUID_SRVS)
95
+ for _ in range(32):
96
+ try:
97
+ response = srvs.hNetrShareEnum(rpc, 1, resumeHandle=resume, preferedMaximumLength=65536, serverName='\\\\' + host)
98
+ more = False
99
+ except srvs.DCERPCSessionError as error:
100
+ if error.get_error_code() != 234: # ERROR_MORE_DATA
101
+ raise
102
+ response, more = error.get_packet(), True
103
+ for row in response['InfoStruct']['ShareInfo']['Level1']['Buffer']:
104
+ if int(row['shi1_type']) & 0xFFFF == 0:
105
+ rows.append({'name': str(row['shi1_netname']).rstrip('\x00'), 'description': str(row['shi1_remark']).rstrip('\x00')})
106
+ if len(rows) == 1000:
107
+ truncated = True
108
+ break
109
+ if not more or truncated:
110
+ break
111
+ next_resume = int(response['ResumeHandle'])
112
+ if next_resume == resume:
113
+ truncated = True
114
+ break
115
+ resume = next_resume
116
+ else:
117
+ truncated = True
118
+ return {'shares': rows, 'truncated': truncated}
119
+ finally:
120
+ rpc.disconnect()
121
+
122
+ finally:
123
+ connection.close()
124
+
125
+
126
+ class Export(c.Structure):
127
+ pass
128
+
129
+
130
+ Export._fields_ = [('directory', c.c_char_p), ('groups', c.c_void_p), ('next', c.POINTER(Export))]
131
+
132
+
133
+ def nfs_exports(host):
134
+ from .nfs import library
135
+ lib = library()
136
+ lib.mount_getexports_timeout.argtypes = [c.c_char_p, c.c_int]
137
+ lib.mount_getexports_timeout.restype = c.POINTER(Export)
138
+ lib.mount_free_export_list.argtypes = [c.POINTER(Export)]
139
+ lib.mount_free_export_list.restype = None
140
+ head = lib.mount_getexports_timeout(host.encode(), 5)
141
+ rows, current = [], head
142
+ try:
143
+ while current and len(rows) < 1000:
144
+ rows.append(current.contents.directory.decode('utf-8', errors='replace'))
145
+ current = current.contents.next
146
+ finally:
147
+ if head:
148
+ lib.mount_free_export_list(head)
149
+ return {'exports': rows, 'truncated': bool(current),
150
+ 'notes': ['Uses mountd export discovery. Empty results do not mean no exports: NFSv4-only servers may require a manually entered export.']}
@@ -0,0 +1,64 @@
1
+ """Best-effort device labels; connection addresses remain unchanged."""
2
+ import json
3
+ import secrets
4
+ import socket
5
+ import struct
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ def netbios_name(host):
11
+ """Ask the target directly for its unique NetBIOS workstation/server name."""
12
+ transaction = secrets.randbits(16)
13
+ query = struct.pack('!6H', transaction, 0, 1, 0, 0, 0)
14
+ query += b'\x20CK' + b'A' * 30 + b'\x00\x00\x21\x00\x01'
15
+ try:
16
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
17
+ sock.settimeout(0.5)
18
+ sock.connect((host, 137))
19
+ sock.send(query)
20
+ data = sock.recv(4096)
21
+ ident, flags, questions, answers, _, _ = struct.unpack_from('!6H', data)
22
+ if ident != transaction or not flags & 0x8000 or flags & 15 or not answers:
23
+ return None
24
+ def skip_name(offset):
25
+ while data[offset]:
26
+ if data[offset] & 0xc0 == 0xc0:
27
+ return offset + 2
28
+ offset += 1 + data[offset]
29
+ return offset + 1
30
+ offset = 12
31
+ for _ in range(questions):
32
+ offset = skip_name(offset) + 4
33
+ offset = skip_name(offset)
34
+ kind, _, _, length = struct.unpack_from('!HHIH', data, offset)
35
+ payload = data[offset + 10:offset + 10 + length]
36
+ if kind != 0x21 or len(payload) != length:
37
+ return None
38
+ for index in range(payload[0]):
39
+ name, suffix, attributes = struct.unpack_from('!15sBH', payload, 1 + index * 18)
40
+ if suffix in (0, 0x20) and not attributes & 0x8000:
41
+ label = name.decode('ascii').strip(' \x00')
42
+ if label and all(ch.isprintable() for ch in label):
43
+ return label
44
+ except (OSError, ValueError, IndexError, struct.error, UnicodeError):
45
+ pass
46
+ return None
47
+
48
+
49
+ def dns_name(host):
50
+ # libc resolver timeouts are not controlled by socket.settimeout. A short-lived
51
+ # subprocess gives DNS a hard deadline without leaving blocked resolver threads.
52
+ try:
53
+ command = ([sys.executable, '--resolve-host', host] if getattr(sys, 'frozen', False) else
54
+ [sys.executable, '-c', 'import socket,json,sys; print(json.dumps(socket.gethostbyaddr(sys.argv[1])[0]))', host])
55
+ result = subprocess.run(
56
+ command,
57
+ capture_output=True, text=True, timeout=1.25, check=True,
58
+ )
59
+ label = json.loads(result.stdout).rstrip('.')
60
+ if label and label != host:
61
+ return label
62
+ except (OSError, subprocess.SubprocessError, ValueError):
63
+ pass
64
+ return None