lanscape 1.3.2a7__py3-none-any.whl → 1.3.2a9__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.

Potentially problematic release.


This version of lanscape might be problematic. Click here for more details.

@@ -0,0 +1,516 @@
1
+ import logging
2
+ import platform
3
+ import ipaddress
4
+ import traceback
5
+ import subprocess
6
+ from time import sleep
7
+ from typing import List, Dict
8
+ import socket
9
+ import struct
10
+ import re
11
+ import psutil
12
+
13
+ from scapy.sendrecv import srp
14
+ from scapy.layers.l2 import ARP, Ether
15
+ from scapy.error import Scapy_Exception
16
+
17
+ from lanscape.libraries.service_scan import scan_service
18
+ from lanscape.libraries.mac_lookup import MacLookup, get_macs
19
+ from lanscape.libraries.ip_parser import get_address_count, MAX_IPS_ALLOWED
20
+ from lanscape.libraries.errors import DeviceError
21
+ from lanscape.libraries.decorators import job_tracker, JobStatsMixin, timeout_enforcer
22
+ from lanscape.libraries.scan_config import ScanType, PingConfig, ArpConfig
23
+
24
+ log = logging.getLogger('NetTools')
25
+
26
+
27
+ class IPAlive(JobStatsMixin):
28
+ """Class to check if a device is alive using ARP and/or ping scans."""
29
+ caught_errors: List[DeviceError] = []
30
+
31
+ @job_tracker
32
+ def is_alive(
33
+ self,
34
+ ip: str,
35
+ scan_type: ScanType = ScanType.BOTH,
36
+ arp_config: ArpConfig = ArpConfig(),
37
+ ping_config: PingConfig = PingConfig()
38
+ ) -> bool:
39
+ """
40
+ Check if a device is alive by performing ARP and/or ping scans.
41
+ """
42
+ if scan_type == ScanType.ARP:
43
+ return self._arp_lookup(ip, arp_config)
44
+ if scan_type == ScanType.PING:
45
+ return self._ping_lookup(ip, ping_config)
46
+ return self._arp_lookup(ip, arp_config) or self._ping_lookup(ip, ping_config)
47
+
48
+ @job_tracker
49
+ def _arp_lookup(
50
+ self, ip: str,
51
+ cfg: ArpConfig = ArpConfig()
52
+ ) -> bool:
53
+ """Perform an ARP lookup to check if the device is alive."""
54
+ enforcer_timeout = cfg.timeout * 1.3
55
+
56
+ @timeout_enforcer(enforcer_timeout, raise_on_timeout=True)
57
+ def do_arp_lookup():
58
+ arp_request = ARP(pdst=ip)
59
+ broadcast = Ether(dst="ff:ff:ff:ff:ff:ff")
60
+ packet = broadcast / arp_request
61
+
62
+ answered, _ = srp(packet, timeout=cfg.timeout, verbose=False)
63
+ self._arp_alive = any(resp.psrc == ip for _, resp in answered)
64
+ return self._arp_alive
65
+
66
+ try:
67
+ for _ in range(cfg.attempts):
68
+ if do_arp_lookup():
69
+ return True
70
+ except Exception as e:
71
+ self.caught_errors.append(DeviceError(e))
72
+ return False
73
+
74
+ @job_tracker
75
+ def _ping_lookup(
76
+ self, host: str,
77
+ cfg: PingConfig = PingConfig()
78
+ ) -> bool:
79
+ """Perform a ping lookup to check if the device is alive."""
80
+ enforcer_timeout = cfg.timeout * cfg.ping_count * 1.3
81
+
82
+ @timeout_enforcer(enforcer_timeout, raise_on_timeout=False)
83
+ def execute_ping(cmd: List[str]) -> subprocess.CompletedProcess:
84
+ return subprocess.run(
85
+ cmd,
86
+ text=True,
87
+ stdout=subprocess.PIPE,
88
+ stderr=subprocess.PIPE,
89
+ check=False
90
+ )
91
+
92
+ cmd = []
93
+ os_name = platform.system().lower()
94
+ if os_name == "windows":
95
+ cmd = ['ping', '-n', str(cfg.ping_count),
96
+ '-w', str(cfg.timeout * 1000)]
97
+ else:
98
+ cmd = ['ping', '-c', str(cfg.ping_count), '-W', str(cfg.timeout)]
99
+
100
+ cmd = cmd + [host]
101
+
102
+ for r in range(cfg.attempts):
103
+ try:
104
+ proc = execute_ping(cmd)
105
+
106
+ if proc and proc.returncode == 0:
107
+ output = proc.stdout.lower()
108
+
109
+ if psutil.WINDOWS or psutil.LINUX:
110
+ if 'ttl' in output:
111
+ self._ping_alive = True
112
+ return self._ping_alive
113
+ if psutil.MACOS or psutil.LINUX:
114
+ if 'ping statistics' in output and '100.0% packet loss' not in output:
115
+ self._ping_alive = True
116
+ return self._ping_alive
117
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
118
+ self.caught_errors.append(DeviceError(e))
119
+ if r < cfg.attempts - 1:
120
+ sleep(cfg.retry_delay)
121
+ self._ping_alive = False
122
+ return self._ping_alive
123
+
124
+
125
+ class Device(IPAlive):
126
+ """Represents a network device with metadata and scanning capabilities."""
127
+ def __init__(self, ip: str):
128
+ super().__init__()
129
+ self.ip: str = ip
130
+ self.alive: bool = None
131
+ self.hostname: str = None
132
+ self.macs: List[str] = []
133
+ self.manufacturer: str = None
134
+ self.ports: List[int] = []
135
+ self.stage: str = 'found'
136
+ self.services: Dict[str, List[int]] = {}
137
+ self.caught_errors: List[DeviceError] = []
138
+ self.log = logging.getLogger('Device')
139
+ self._mac_lookup = MacLookup()
140
+
141
+ def get_metadata(self):
142
+ """Retrieve metadata such as hostname and MAC addresses."""
143
+ if self.alive:
144
+ self.hostname = self._get_hostname()
145
+ self.macs = self._get_mac_addresses()
146
+
147
+ def dict(self) -> dict:
148
+ """Convert the device object to a dictionary."""
149
+ obj = vars(self).copy()
150
+ obj.pop('log')
151
+ obj.pop('job_stats', None) # Remove job_stats if it exists
152
+ primary_mac = self.get_mac()
153
+ obj['mac_addr'] = primary_mac
154
+ obj['manufacturer'] = self._get_manufacturer(primary_mac)
155
+
156
+ return obj
157
+
158
+ def test_port(self, port: int) -> bool:
159
+ """Test if a specific port is open on the device."""
160
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
161
+ sock.settimeout(1)
162
+ result = sock.connect_ex((self.ip, port))
163
+ sock.close()
164
+ if result == 0:
165
+ self.ports.append(port)
166
+ return True
167
+ return False
168
+
169
+ @job_tracker
170
+ def scan_service(self, port: int):
171
+ """Scan a specific port for services."""
172
+ service = scan_service(self.ip, port)
173
+ service_ports = self.services.get(service, [])
174
+ service_ports.append(port)
175
+ self.services[service] = service_ports
176
+
177
+ def get_mac(self):
178
+ """Get the primary MAC address of the device."""
179
+ if not self.macs:
180
+ return ''
181
+ return mac_selector.choose_mac(self.macs)
182
+
183
+ @job_tracker
184
+ def _get_mac_addresses(self):
185
+ """Get the possible MAC addresses of a network device given its IP address."""
186
+ macs = get_macs(self.ip)
187
+ mac_selector.import_macs(macs)
188
+ return macs
189
+
190
+ @job_tracker
191
+ def _get_hostname(self):
192
+ """Get the hostname of a network device given its IP address."""
193
+ try:
194
+ hostname = socket.gethostbyaddr(self.ip)[0]
195
+ return hostname
196
+ except socket.herror as e:
197
+ self.caught_errors.append(DeviceError(e))
198
+ return None
199
+
200
+ @job_tracker
201
+ def _get_manufacturer(self, mac_addr=None):
202
+ """Get the manufacturer of a network device given its MAC address."""
203
+ return self._mac_lookup.lookup_vendor(mac_addr) if mac_addr else None
204
+
205
+
206
+ class MacSelector:
207
+ """
208
+ Essentially filters out bad mac addresses
209
+ you send in a list of macs,
210
+ it will return the one that has been seen the least
211
+ (ideally meaning it is the most likely to be the correct one)
212
+ this was added because some lookups return multiple macs,
213
+ usually the hwid of a vpn tunnel etc
214
+ """
215
+
216
+ def __init__(self):
217
+ self.macs = {}
218
+
219
+ def choose_mac(self, macs: List[str]) -> str:
220
+ """
221
+ Choose the most appropriate MAC address from a list.
222
+ The mac address that has been seen the least is returned.
223
+ """
224
+ if len(macs) == 1:
225
+ return macs[0]
226
+ lowest = 9999
227
+ lowest_i = -1
228
+ for mac in macs:
229
+ if self.macs[mac] < lowest:
230
+ lowest = self.macs[mac]
231
+ lowest_i = macs.index(mac)
232
+ return macs[lowest_i] if lowest_i != -1 else None
233
+
234
+ def import_macs(self, macs: List[str]):
235
+ """
236
+ Import a list of MAC addresses associated with a device.
237
+ """
238
+ for mac in macs:
239
+ self.macs[mac] = self.macs.get(mac, 0) + 1
240
+
241
+ def clear(self):
242
+ self.macs = {}
243
+
244
+
245
+ mac_selector = MacSelector()
246
+
247
+
248
+ def get_ip_address(interface: str):
249
+ """
250
+ Get the IP address of a network interface on Windows, Linux, or macOS.
251
+ """
252
+ def unix_like(): # Combined Linux and macOS
253
+ try:
254
+ # pylint: disable=import-outside-toplevel, import-error
255
+ import fcntl
256
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
257
+ ip_address = socket.inet_ntoa(fcntl.ioctl(
258
+ sock.fileno(),
259
+ 0x8915, # SIOCGIFADDRf
260
+ struct.pack('256s', interface[:15].encode('utf-8'))
261
+ )[20:24])
262
+ return ip_address
263
+ except IOError:
264
+ return None
265
+
266
+ def windows():
267
+ # Get network interfaces and IP addresses using psutil
268
+ net_if_addrs = psutil.net_if_addrs()
269
+ if interface in net_if_addrs:
270
+ for addr in net_if_addrs[interface]:
271
+ if addr.family == socket.AF_INET: # Check for IPv4
272
+ return addr.address
273
+ return None
274
+
275
+ # Call the appropriate function based on the platform
276
+ if psutil.WINDOWS:
277
+ return windows()
278
+ else: # Linux, macOS, and other Unix-like systems
279
+ return unix_like()
280
+
281
+
282
+ def get_netmask(interface: str):
283
+ """
284
+ Get the netmask of a network interface.
285
+ """
286
+
287
+ def unix_like(): # Combined Linux and macOS
288
+ try:
289
+ # pylint: disable=import-outside-toplevel, import-error
290
+ import fcntl
291
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
292
+ netmask = socket.inet_ntoa(fcntl.ioctl(
293
+ sock.fileno(),
294
+ 0x891b, # SIOCGIFNETMASK
295
+ struct.pack('256s', interface[:15].encode('utf-8'))
296
+ )[20:24])
297
+ return netmask
298
+ except IOError:
299
+ return None
300
+
301
+ def windows():
302
+ output = subprocess.check_output("ipconfig", shell=True).decode()
303
+ # Use a regular expression to match both interface and subnet mask
304
+ interface_section_pattern = rf"{interface}.*?Subnet Mask.*?:\s+(\d+\.\d+\.\d+\.\d+)"
305
+ # Use re.S to allow dot to match newline
306
+ match = re.search(interface_section_pattern, output, re.S)
307
+ if match:
308
+ return match.group(1)
309
+ return None
310
+
311
+ if psutil.WINDOWS:
312
+ return windows()
313
+ else: # Linux, macOS, and other Unix-like systems
314
+ return unix_like()
315
+
316
+
317
+ def get_cidr_from_netmask(netmask: str):
318
+ """
319
+ Get the CIDR notation of a netmask.
320
+ """
321
+ binary_str = ''.join([bin(int(x)).lstrip('0b').zfill(8)
322
+ for x in netmask.split('.')])
323
+ return str(len(binary_str.rstrip('0')))
324
+
325
+
326
+ def get_primary_interface():
327
+ """
328
+ Get the primary network interface that is likely handling internet traffic.
329
+ Uses heuristics to identify the most probable interface.
330
+ """
331
+ # Try to find the interface with the default gateway
332
+ try:
333
+ if psutil.WINDOWS:
334
+ # On Windows, parse route print output
335
+ output = subprocess.check_output(
336
+ "route print 0.0.0.0", shell=True, text=True)
337
+ lines = output.strip().split('\n')
338
+ for line in lines:
339
+ if '0.0.0.0' in line and 'Gateway' not in line: # Skip header
340
+ parts = [p for p in line.split() if p]
341
+ if len(parts) >= 4:
342
+ interface_idx = parts[3]
343
+ # Find interface name in the output
344
+ for iface_name, addrs in psutil.net_if_addrs().items():
345
+ if str(interface_idx) in iface_name:
346
+ return iface_name
347
+ else:
348
+ # Linux/Unix/Mac - use ip route or netstat
349
+ try:
350
+ output = subprocess.check_output(
351
+ "ip route show default 2>/dev/null || netstat -rn | grep default", shell=True, text=True)
352
+ for line in output.split('\n'):
353
+ if 'default via' in line and 'dev' in line:
354
+ return line.split('dev')[1].split()[0]
355
+ elif 'default' in line:
356
+ parts = line.split()
357
+ if len(parts) > 3:
358
+ # Interface is usually the last column
359
+ return parts[-1]
360
+ except (subprocess.SubprocessError, IndexError, FileNotFoundError):
361
+ pass
362
+ except Exception as e:
363
+ log.debug(f"Error determining primary interface: {e}")
364
+
365
+ # Fallback: Identify likely candidates based on heuristics
366
+ candidates = []
367
+
368
+ for interface, addrs in psutil.net_if_addrs().items():
369
+ stats = psutil.net_if_stats().get(interface)
370
+ if stats and stats.isup:
371
+ ipv4_addrs = [
372
+ addr for addr in addrs if addr.family == socket.AF_INET]
373
+ if ipv4_addrs:
374
+ # Skip loopback and common virtual interfaces
375
+ is_loopback = any(addr.address.startswith('127.')
376
+ for addr in ipv4_addrs)
377
+ is_virtual = any(name in interface.lower() for name in
378
+ ['loop', 'vmnet', 'vbox', 'docker', 'virtual', 'veth'])
379
+
380
+ if not is_loopback and not is_virtual:
381
+ candidates.append(interface)
382
+
383
+ # Prioritize interfaces with names typically used for physical connections
384
+ for prefix in ['eth', 'en', 'wlan', 'wifi', 'wl', 'wi']:
385
+ for interface in candidates:
386
+ if interface.lower().startswith(prefix):
387
+ return interface
388
+
389
+ # Otherwise return the first candidate or None
390
+ return candidates[0] if candidates else None
391
+
392
+
393
+ def get_host_ip_mask(ip_with_cidr: str):
394
+ """
395
+ Get the IP address and netmask of a network interface.
396
+ """
397
+ cidr = ip_with_cidr.split('/')[1]
398
+ network = ipaddress.ip_network(ip_with_cidr, strict=False)
399
+ return f'{network.network_address}/{cidr}'
400
+
401
+
402
+ def get_network_subnet(interface=None):
403
+ """
404
+ Get the network subnet for a given interface.
405
+ Uses network_from_snicaddr for conversion.
406
+ Default is primary interface.
407
+ """
408
+ interface = interface or get_primary_interface()
409
+
410
+ try:
411
+ addrs = psutil.net_if_addrs()
412
+ if interface in addrs:
413
+ for snicaddr in addrs[interface]:
414
+ if snicaddr.family == socket.AF_INET and snicaddr.address and snicaddr.netmask:
415
+ subnet = network_from_snicaddr(snicaddr)
416
+ if subnet:
417
+ return subnet
418
+ except Exception:
419
+ log.info(f'Unable to parse subnet for interface: {interface}')
420
+ log.debug(traceback.format_exc())
421
+ return None
422
+
423
+
424
+ def get_all_network_subnets():
425
+ """
426
+ Get the primary network interface.
427
+ """
428
+ addrs = psutil.net_if_addrs()
429
+ gateways = psutil.net_if_stats()
430
+ subnets = []
431
+
432
+ for interface, snicaddrs in addrs.items():
433
+ for snicaddr in snicaddrs:
434
+ if snicaddr.family == socket.AF_INET and gateways[interface].isup:
435
+
436
+ subnet = network_from_snicaddr(snicaddr)
437
+
438
+ if subnet:
439
+ subnets.append({
440
+ 'subnet': subnet,
441
+ 'address_cnt': get_address_count(subnet)
442
+ })
443
+
444
+ return subnets
445
+
446
+
447
+ def network_from_snicaddr(snicaddr: psutil._common.snicaddr) -> str:
448
+ """
449
+ Convert a psutil snicaddr object to a human-readable string.
450
+ """
451
+ if not snicaddr.address or not snicaddr.netmask:
452
+ return None
453
+ elif snicaddr.family == socket.AF_INET:
454
+ addr = f"{snicaddr.address}/{get_cidr_from_netmask(snicaddr.netmask)}"
455
+ elif snicaddr.family == socket.AF_INET6:
456
+ addr = f"{snicaddr.address}/{snicaddr.netmask}"
457
+ else:
458
+ return f"{snicaddr.address}"
459
+ return get_host_ip_mask(addr)
460
+
461
+
462
+ def smart_select_primary_subnet(subnets: List[dict] = None) -> str:
463
+ """
464
+ Intelligently select the primary subnet that is most likely handling internet traffic.
465
+
466
+ Selection priority:
467
+ 1. Subnet associated with the primary interface (with default gateway)
468
+ 2. Largest subnet within maximum allowed IP range
469
+ 3. First subnet in the list as fallback
470
+
471
+ Returns an empty string if no subnets are available.
472
+ """
473
+ subnets = subnets or get_all_network_subnets()
474
+
475
+ if not subnets:
476
+ return ""
477
+
478
+ # First priority: Get subnet for the primary interface
479
+ primary_if = get_primary_interface()
480
+ if primary_if:
481
+ primary_subnet = get_network_subnet(primary_if)
482
+ if primary_subnet:
483
+ # Return this subnet if it's within our list
484
+ for subnet in subnets:
485
+ if subnet["subnet"] == primary_subnet:
486
+ return primary_subnet
487
+
488
+ # Second priority: Find a reasonable sized subnet (existing logic)
489
+ selected = {}
490
+ for subnet in subnets:
491
+ if selected.get("address_cnt", 0) < subnet["address_cnt"] < MAX_IPS_ALLOWED:
492
+ selected = subnet
493
+
494
+ # Third priority: Just take the first subnet if nothing else matched
495
+ if not selected and subnets:
496
+ selected = subnets[0]
497
+
498
+ return selected.get("subnet", "")
499
+
500
+
501
+ def is_arp_supported():
502
+ """
503
+ Check if ARP requests are supported on the current platform.
504
+ """
505
+ try:
506
+ arp_request = ARP(pdst='0.0.0.0')
507
+ broadcast = Ether(dst="ff:ff:ff:ff:ff:ff")
508
+ packet = broadcast / arp_request
509
+
510
+ srp(packet, timeout=0, verbose=False)
511
+ return True
512
+ # Scapy_Exception = MacOS
513
+ # PermissionError = Linux
514
+ # RuntimeError = Windows
515
+ except (Scapy_Exception, PermissionError, RuntimeError):
516
+ return False
@@ -0,0 +1,67 @@
1
+ import json
2
+ from typing import List
3
+ from pathlib import Path
4
+ from .app_scope import ResourceManager
5
+
6
+ PORT_DIR = 'ports'
7
+
8
+
9
+ class PortManager:
10
+ def __init__(self):
11
+ Path(PORT_DIR).mkdir(parents=True, exist_ok=True)
12
+ self.rm = ResourceManager(PORT_DIR)
13
+
14
+ def get_port_lists(self) -> List[str]:
15
+ return [f.replace('.json', '') for f in self.rm.list() if f.endswith('.json')]
16
+
17
+ def get_port_list(self, port_list: str) -> dict:
18
+
19
+ if port_list not in self.get_port_lists():
20
+ raise ValueError(
21
+ f"Port list '{port_list}' does not exist. Available port lists: {
22
+ self.get_port_lists()}")
23
+
24
+ data = json.loads(self.rm.get(f'{port_list}.json'))
25
+
26
+ return data if self.validate_port_data(data) else None
27
+
28
+ def create_port_list(self, port_list: str, data: dict) -> bool:
29
+ if port_list in self.get_port_lists():
30
+ return False
31
+ if not self.validate_port_data(data):
32
+ return False
33
+
34
+ self.rm.create(f'{port_list}.json', json.dumps(data, indent=2))
35
+
36
+ return True
37
+
38
+ def update_port_list(self, port_list: str, data: dict) -> bool:
39
+ if port_list not in self.get_port_lists():
40
+ return False
41
+ if not self.validate_port_data(data):
42
+ return False
43
+
44
+ self.rm.update(f'{port_list}.json', json.dumps(data, indent=2))
45
+
46
+ return True
47
+
48
+ def delete_port_list(self, port_list: str) -> bool:
49
+ if port_list not in self.get_port_lists():
50
+ return False
51
+
52
+ self.rm.delete(f'{port_list}.json')
53
+
54
+ return True
55
+
56
+ def validate_port_data(self, port_data: dict) -> bool:
57
+ try:
58
+ for port, service in port_data.items():
59
+ port = int(port) # throws if not int
60
+ if not isinstance(service, str):
61
+ return False
62
+
63
+ if not 0 <= port <= 65535:
64
+ return False
65
+ return True
66
+ except BaseException:
67
+ return False
@@ -0,0 +1,54 @@
1
+ import argparse
2
+ from dataclasses import dataclass, fields
3
+ import argparse
4
+ from typing import Any, Dict
5
+
6
+
7
+ @dataclass
8
+ class RuntimeArgs:
9
+ reloader: bool = False
10
+ port: int = 5001
11
+ logfile: bool = False
12
+ loglevel: str = 'INFO'
13
+ flask_logging: bool = False
14
+ persistent: bool = False
15
+
16
+
17
+ def parse_args() -> RuntimeArgs:
18
+ parser = argparse.ArgumentParser(description='LANscape')
19
+
20
+ parser.add_argument('--reloader', action='store_true',
21
+ help='Use flask\'s reloader (helpful for local development)')
22
+ parser.add_argument('--port', type=int, default=5001,
23
+ help='Port to run the webserver on')
24
+ parser.add_argument('--logfile', action='store_true',
25
+ help='Log output to lanscape.log')
26
+ parser.add_argument('--loglevel', default='INFO', help='Set the log level')
27
+ parser.add_argument('--flask-logging', action='store_true',
28
+ help='Enable flask logging (disables click output)')
29
+ parser.add_argument('--persistent', action='store_true',
30
+ help='Don\'t exit after browser is closed')
31
+
32
+ # Parse the arguments
33
+ args = parser.parse_args()
34
+
35
+ # Dynamically map argparse Namespace to the Args dataclass
36
+ # Convert the Namespace to a dictionary
37
+ args_dict: Dict[str, Any] = vars(args)
38
+ field_names = {field.name for field in fields(
39
+ RuntimeArgs)} # Get dataclass field names
40
+
41
+ # Only pass arguments that exist in the Args dataclass
42
+ filtered_args = {name: args_dict[name]
43
+ for name in field_names if name in args_dict}
44
+
45
+ # Deal with loglevel formatting
46
+ filtered_args['loglevel'] = filtered_args['loglevel'].upper()
47
+
48
+ valid_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
49
+ if filtered_args['loglevel'] not in valid_levels:
50
+ raise ValueError(
51
+ f"Invalid log level: {filtered_args['loglevel']}. Must be one of: {valid_levels}")
52
+
53
+ # Return the dataclass instance with the dynamically assigned values
54
+ return RuntimeArgs(**filtered_args)