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.
- remote_fs_browser/__init__.py +11 -0
- remote_fs_browser/__main__.py +8 -0
- remote_fs_browser/backends.py +182 -0
- remote_fs_browser/cli.py +162 -0
- remote_fs_browser/defaults.py +91 -0
- remote_fs_browser/discovery.py +150 -0
- remote_fs_browser/hostnames.py +64 -0
- remote_fs_browser/http.py +347 -0
- remote_fs_browser/nfs.py +151 -0
- remote_fs_browser/policy.py +57 -0
- remote_fs_browser/sessions.py +307 -0
- remote_fs_browser/store.py +115 -0
- remote_fs_browser/web/browser.js +898 -0
- remote_fs_browser/web/demo.js +75 -0
- remote_fs_browser/web/index.html +115 -0
- remote_fs_browser-0.2.0.dist-info/METADATA +252 -0
- remote_fs_browser-0.2.0.dist-info/RECORD +21 -0
- remote_fs_browser-0.2.0.dist-info/WHEEL +5 -0
- remote_fs_browser-0.2.0.dist-info/entry_points.txt +3 -0
- remote_fs_browser-0.2.0.dist-info/licenses/LICENSE +21 -0
- remote_fs_browser-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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,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)
|
remote_fs_browser/cli.py
ADDED
|
@@ -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
|