lanscape 1.3.8a1__py3-none-any.whl → 2.4.0a2__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.

Files changed (58) hide show
  1. lanscape/__init__.py +8 -4
  2. lanscape/{libraries → core}/app_scope.py +21 -3
  3. lanscape/core/decorators.py +231 -0
  4. lanscape/{libraries → core}/device_alive.py +83 -16
  5. lanscape/{libraries → core}/ip_parser.py +2 -26
  6. lanscape/{libraries → core}/net_tools.py +209 -66
  7. lanscape/{libraries → core}/runtime_args.py +6 -0
  8. lanscape/{libraries → core}/scan_config.py +103 -5
  9. lanscape/core/service_scan.py +222 -0
  10. lanscape/{libraries → core}/subnet_scan.py +30 -14
  11. lanscape/{libraries → core}/version_manager.py +15 -17
  12. lanscape/resources/ports/test_port_list_scan.json +4 -0
  13. lanscape/resources/services/definitions.jsonc +576 -400
  14. lanscape/ui/app.py +17 -5
  15. lanscape/ui/blueprints/__init__.py +1 -1
  16. lanscape/ui/blueprints/api/port.py +15 -1
  17. lanscape/ui/blueprints/api/scan.py +1 -1
  18. lanscape/ui/blueprints/api/tools.py +4 -4
  19. lanscape/ui/blueprints/web/routes.py +29 -2
  20. lanscape/ui/main.py +46 -19
  21. lanscape/ui/shutdown_handler.py +2 -2
  22. lanscape/ui/static/css/style.css +186 -20
  23. lanscape/ui/static/js/core.js +14 -0
  24. lanscape/ui/static/js/main.js +30 -2
  25. lanscape/ui/static/js/quietReload.js +3 -0
  26. lanscape/ui/static/js/scan-config.js +56 -6
  27. lanscape/ui/templates/base.html +6 -8
  28. lanscape/ui/templates/core/head.html +1 -1
  29. lanscape/ui/templates/info.html +20 -5
  30. lanscape/ui/templates/main.html +33 -36
  31. lanscape/ui/templates/scan/config.html +214 -176
  32. lanscape/ui/templates/scan/device-detail.html +111 -0
  33. lanscape/ui/templates/scan/ip-table-row.html +17 -83
  34. lanscape/ui/templates/scan/ip-table.html +5 -5
  35. lanscape/ui/ws/__init__.py +31 -0
  36. lanscape/ui/ws/delta.py +170 -0
  37. lanscape/ui/ws/handlers/__init__.py +20 -0
  38. lanscape/ui/ws/handlers/base.py +145 -0
  39. lanscape/ui/ws/handlers/port.py +184 -0
  40. lanscape/ui/ws/handlers/scan.py +352 -0
  41. lanscape/ui/ws/handlers/tools.py +145 -0
  42. lanscape/ui/ws/protocol.py +86 -0
  43. lanscape/ui/ws/server.py +375 -0
  44. {lanscape-1.3.8a1.dist-info → lanscape-2.4.0a2.dist-info}/METADATA +18 -3
  45. lanscape-2.4.0a2.dist-info/RECORD +85 -0
  46. {lanscape-1.3.8a1.dist-info → lanscape-2.4.0a2.dist-info}/WHEEL +1 -1
  47. lanscape-2.4.0a2.dist-info/entry_points.txt +2 -0
  48. lanscape/libraries/decorators.py +0 -170
  49. lanscape/libraries/service_scan.py +0 -50
  50. lanscape/libraries/web_browser.py +0 -210
  51. lanscape-1.3.8a1.dist-info/RECORD +0 -74
  52. /lanscape/{libraries → core}/__init__.py +0 -0
  53. /lanscape/{libraries → core}/errors.py +0 -0
  54. /lanscape/{libraries → core}/logger.py +0 -0
  55. /lanscape/{libraries → core}/mac_lookup.py +0 -0
  56. /lanscape/{libraries → core}/port_manager.py +0 -0
  57. {lanscape-1.3.8a1.dist-info → lanscape-2.4.0a2.dist-info}/licenses/LICENSE +0 -0
  58. {lanscape-1.3.8a1.dist-info → lanscape-2.4.0a2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,222 @@
1
+ """Service scanning module for identifying services running on network ports.
2
+ """
3
+
4
+ from typing import Optional, Union
5
+ import asyncio
6
+ import logging
7
+ import traceback
8
+
9
+ from lanscape.core.app_scope import ResourceManager
10
+ from lanscape.core.scan_config import ServiceScanConfig, ServiceScanStrategy
11
+
12
+ # asyncio complains more than it needs to
13
+ logging.getLogger("asyncio").setLevel(logging.WARNING)
14
+
15
+ log = logging.getLogger('ServiceScan')
16
+ SERVICES = ResourceManager('services').get_jsonc('definitions.jsonc')
17
+
18
+ # skip printer ports because they cause blank pages to be printed
19
+ PRINTER_PORTS = [9100, 631]
20
+
21
+
22
+ async def _try_probe(
23
+ ip: str,
24
+ port: int,
25
+ payload: Optional[Union[str, bytes]] = None,
26
+ *,
27
+ timeout: float = 5.0,
28
+ read_len: int = 1024,
29
+ ) -> Optional[str]:
30
+ """
31
+ Open a connection, optionally send a payload, and read a single response chunk.
32
+ Returns the decoded response string or None.
33
+ """
34
+ try:
35
+ reader, writer = await asyncio.wait_for(
36
+ asyncio.open_connection(ip, port), timeout=timeout
37
+ )
38
+ try:
39
+ if payload is not None:
40
+ data = payload if isinstance(
41
+ payload, (bytes, bytearray)) else str(payload).encode(
42
+ "utf-8", errors="ignore")
43
+ writer.write(data)
44
+ await writer.drain()
45
+ try:
46
+ response = await asyncio.wait_for(reader.read(read_len), timeout=timeout / 2)
47
+ except asyncio.TimeoutError:
48
+ response = b""
49
+ resp_str = response.decode("utf-8", errors="ignore") if response else ""
50
+ return resp_str if resp_str else None
51
+ finally:
52
+ # Guarded close to avoid surfacing connection-lost noise
53
+ try:
54
+ writer.close()
55
+ except Exception:
56
+ pass
57
+ try:
58
+ await asyncio.wait_for(writer.wait_closed(), timeout=0.5)
59
+ except Exception:
60
+ pass
61
+ except Exception as e:
62
+ # Suppress common/expected network errors that simply indicate no useful banner
63
+ expected_types = (ConnectionResetError, ConnectionRefusedError, TimeoutError, OSError)
64
+ expected_errnos = {10054, 10061, 10060} # reset, refused, timeout (Win specific)
65
+ eno = getattr(e, 'errno', None)
66
+ if isinstance(e, expected_types) and (eno in expected_errnos or eno is None):
67
+ return None
68
+ log.debug(f"Probe error on {ip}:{port} - {repr(e)}")
69
+ return None
70
+
71
+
72
+ async def _multi_probe_generic(
73
+ ip: str, port: int, cfg: ServiceScanConfig
74
+ ) -> Optional[str]:
75
+ """
76
+ Run a small set of generic probes in parallel and return the first non-empty response.
77
+ """
78
+ probes = get_port_probes(port, cfg.lookup_type)
79
+
80
+ semaphore = asyncio.Semaphore(cfg.max_concurrent_probes)
81
+
82
+ async def limited_probe(ip, port, payload, timeout_val):
83
+ async with semaphore:
84
+ return await _try_probe(
85
+ ip, port, payload,
86
+ timeout=timeout_val
87
+ )
88
+
89
+ tasks = [
90
+ asyncio.create_task(
91
+ limited_probe(ip, port, p, cfg.timeout)
92
+ )
93
+ for p in probes
94
+ ]
95
+
96
+ try:
97
+ for fut in asyncio.as_completed(tasks, timeout=cfg.timeout):
98
+ try:
99
+ resp = await fut
100
+ except Exception:
101
+ resp = None
102
+ if resp and resp.strip():
103
+ # Cancel remaining tasks
104
+ for t in tasks:
105
+ if not t.done():
106
+ t.cancel()
107
+ return resp
108
+ except asyncio.TimeoutError:
109
+ pass
110
+ finally:
111
+ # Ensure remaining tasks are cancelled and awaited to suppress warnings
112
+ for t in tasks:
113
+ if not t.done():
114
+ t.cancel()
115
+ await asyncio.gather(*tasks, return_exceptions=True)
116
+
117
+ return None
118
+
119
+
120
+ def get_port_probes(port: int, strategy: ServiceScanStrategy):
121
+ """
122
+ Return a list of probe payloads based on the port and strategy.
123
+ """
124
+ # For now, we use generic probes for all ports.
125
+ # This can be extended to use specific probes per port/service.
126
+
127
+ probes = [
128
+ None, # banner-first protocols (SSH/FTP/SMTP/etc.)
129
+ b"\r\n", # nudge for many line-oriented services
130
+ b"HELP\r\n", # sometimes yields usage/help (SMTP/POP/IMAP-ish)
131
+ b"OPTIONS * HTTP/1.0\r\n\r\n", # elicit Server header without path
132
+ b"HEAD / HTTP/1.0\r\n\r\n", # basic HTTP
133
+ b"QUIT\r\n", # graceful close if understood
134
+ ]
135
+
136
+ if strategy == ServiceScanStrategy.LAZY:
137
+ return probes
138
+
139
+ if strategy == ServiceScanStrategy.BASIC:
140
+ for _, detail in SERVICES.items():
141
+ if port in detail.get("ports", []):
142
+ if probe := detail.get("probe", ''):
143
+ probes.append(probe)
144
+ return probes
145
+
146
+ if strategy == ServiceScanStrategy.AGGRESSIVE:
147
+ for _, detail in SERVICES.items():
148
+ if probe := detail.get("probe", ''):
149
+ probes.append(probe)
150
+ return probes
151
+
152
+ return [None] # Default to banner grab only
153
+
154
+
155
+ def scan_service(ip: str, port: int, cfg: ServiceScanConfig) -> str:
156
+ """
157
+ Synchronous function that attempts to identify the service
158
+ running on a given port.
159
+ TODO: This is AI slop and needs to be reworked properly.
160
+ """
161
+
162
+ async def _async_scan_service(
163
+ ip: str, port: int,
164
+ cfg: ServiceScanConfig
165
+ ) -> str:
166
+ if port in PRINTER_PORTS:
167
+ return "Printer"
168
+
169
+ try:
170
+ # Run multiple generic probes concurrently and take first useful response
171
+ response_str = await _multi_probe_generic(ip, port, cfg)
172
+ if not response_str:
173
+ return "Unknown"
174
+
175
+ log.debug(f"Service scan response from {ip}:{port} - {response_str}")
176
+
177
+ # Analyze the response to identify the service
178
+ for service, config in SERVICES.items():
179
+ if any(hint.lower() in response_str.lower() for hint in config.get("hints", [])):
180
+ return service
181
+ except asyncio.TimeoutError:
182
+ log.warning(f"Timeout scanning {ip}:{port}")
183
+ except Exception as e:
184
+ log.error(f"Error scanning {ip}:{port}: {str(e)}")
185
+ log.debug(traceback.format_exc())
186
+ return "Unknown"
187
+
188
+ # Create and properly manage event loop to avoid file descriptor leaks
189
+ # Using new_event_loop + explicit close is safer in threaded environments
190
+ # than asyncio.run() which can leave resources open under heavy load
191
+ loop = None
192
+ try:
193
+ try:
194
+ # Try to get existing loop first (if running in async context)
195
+ loop = asyncio.get_running_loop()
196
+ # If we're already in an async context, just await directly
197
+ return asyncio.run_coroutine_threadsafe(
198
+ _async_scan_service(ip, port, cfg=cfg), loop
199
+ ).result(timeout=cfg.timeout + 5)
200
+ except RuntimeError:
201
+ # No running loop, create a new one
202
+ loop = asyncio.new_event_loop()
203
+ asyncio.set_event_loop(loop)
204
+ try:
205
+ return loop.run_until_complete(_async_scan_service(ip, port, cfg=cfg))
206
+ finally:
207
+ # Clean up the loop properly
208
+ try:
209
+ # Cancel all remaining tasks
210
+ pending = asyncio.all_tasks(loop)
211
+ for task in pending:
212
+ task.cancel()
213
+ # Run loop once more to process cancellations
214
+ if pending:
215
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
216
+ except Exception:
217
+ pass
218
+ finally:
219
+ loop.close()
220
+ except Exception as e:
221
+ log.error(f"Event loop error scanning {ip}:{port}: {e}")
222
+ return "Unknown"
@@ -20,11 +20,13 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
20
20
  from tabulate import tabulate
21
21
 
22
22
  # Local imports
23
- from lanscape.libraries.scan_config import ScanConfig
24
- from lanscape.libraries.decorators import job_tracker, terminator, JobStats
25
- from lanscape.libraries.net_tools import Device
26
- from lanscape.libraries.errors import SubnetScanTerminationFailure
27
- from lanscape.libraries.device_alive import is_device_alive
23
+ from lanscape.core.scan_config import ScanConfig
24
+ from lanscape.core.decorators import job_tracker, terminator, JobStats
25
+ from lanscape.core.net_tools import (
26
+ Device, is_internal_block, scan_config_uses_arp
27
+ )
28
+ from lanscape.core.errors import SubnetScanTerminationFailure
29
+ from lanscape.core.device_alive import is_device_alive
28
30
 
29
31
 
30
32
  class SubnetScanner():
@@ -62,7 +64,10 @@ class SubnetScanner():
62
64
  """
63
65
  self._set_stage('scanning devices')
64
66
  self.running = True
65
- with ThreadPoolExecutor(max_workers=self.cfg.t_cnt('isalive')) as executor:
67
+ with ThreadPoolExecutor(
68
+ max_workers=self.cfg.t_cnt('isalive'),
69
+ thread_name_prefix="DeviceAlive") as executor:
70
+
66
71
  futures = {executor.submit(self._get_host_details, str(
67
72
  ip)): str(ip) for ip in self.subnet}
68
73
  for future in as_completed(futures):
@@ -187,7 +192,7 @@ class SubnetScanner():
187
192
  """
188
193
  Get the MAC address and open ports of the given host.
189
194
  """
190
- device = Device(host)
195
+ device = Device(ip=host)
191
196
  device.alive = self._ping(device)
192
197
  self.results.scanned()
193
198
  if not device.alive:
@@ -199,7 +204,8 @@ class SubnetScanner():
199
204
 
200
205
  @terminator
201
206
  def _scan_network_ports(self):
202
- with ThreadPoolExecutor(max_workers=self.cfg.t_cnt('port_scan')) as executor:
207
+ with ThreadPoolExecutor(max_workers=self.cfg.t_cnt('port_scan'),
208
+ thread_name_prefix="DevicePortScanParent") as executor:
203
209
  futures = {executor.submit(
204
210
  self._scan_ports, device): device for device in self.results.devices}
205
211
  for future in futures:
@@ -210,7 +216,9 @@ class SubnetScanner():
210
216
  def _scan_ports(self, device: Device):
211
217
  self.log.debug(f'[{device.ip}] Initiating port scan')
212
218
  device.stage = 'scanning'
213
- with ThreadPoolExecutor(max_workers=self.cfg.t_cnt('port_test')) as executor:
219
+ with ThreadPoolExecutor(
220
+ max_workers=self.cfg.t_cnt('port_test'),
221
+ thread_name_prefix=f"{device.ip}-PortScan") as executor:
214
222
  futures = {executor.submit(self._test_port, device, int(
215
223
  port)): port for port in self.ports}
216
224
  for future in futures:
@@ -226,9 +234,9 @@ class SubnetScanner():
226
234
  If port open, determine service.
227
235
  Device class handles tracking open ports.
228
236
  """
229
- is_alive = host.test_port(port)
237
+ is_alive = host.test_port(port, self.cfg.port_scan_config)
230
238
  if is_alive and self.cfg.task_scan_port_services:
231
- host.scan_service(port)
239
+ host.scan_service(port, self.cfg.service_scan_config)
232
240
  return is_alive
233
241
 
234
242
  @terminator
@@ -264,6 +272,7 @@ class ScannerResults:
264
272
  # Scan statistics
265
273
  self.devices_total: int = len(list(scan.subnet))
266
274
  self.devices_scanned: int = 0
275
+ self.port_list_length: int = len(scan.ports)
267
276
  self.devices: List[Device] = []
268
277
 
269
278
  # Status tracking
@@ -294,11 +303,11 @@ class ScannerResults:
294
303
  Calculate the runtime of the scan in seconds.
295
304
 
296
305
  Returns:
297
- int: Runtime in seconds
306
+ float: Runtime in seconds
298
307
  """
299
308
  if self.scan.running:
300
- return int(time() - self.start_time)
301
- return int(self.end_time - self.start_time)
309
+ return time() - self.start_time
310
+ return self.end_time - self.start_time
302
311
 
303
312
  def export(self, out_type=dict) -> Union[str, dict]:
304
313
  """
@@ -378,6 +387,13 @@ class ScanManager:
378
387
  Returns:
379
388
  SubnetScanner: The newly created scan instance
380
389
  """
390
+ if not is_internal_block(config.subnet) and scan_config_uses_arp(config):
391
+ self.log.warning(
392
+ f"ARP scanning detected for external subnet '{config.subnet}'. "
393
+ "ARP requests typically only work within the local network segment. "
394
+ "Consider using ICMP scanning for external IP ranges."
395
+ )
396
+
381
397
  scan = SubnetScanner(config)
382
398
  self._start(scan)
383
399
  self.log.info(f'Scan started - {config}')
@@ -11,16 +11,14 @@ from random import randint
11
11
 
12
12
  import requests
13
13
 
14
- from .app_scope import is_local_run
14
+ from lanscape.core.app_scope import is_local_run
15
+ from lanscape.core.decorators import run_once
15
16
 
16
17
  log = logging.getLogger('VersionManager')
17
18
 
18
19
  PACKAGE = 'lanscape'
19
20
  LOCAL_VERSION = '0.0.0'
20
21
 
21
- # Used to cache PyPI version during runtime
22
- LATEST_VERSION = None
23
-
24
22
 
25
23
  def is_update_available(package=PACKAGE) -> bool:
26
24
  """
@@ -49,6 +47,7 @@ def is_update_available(package=PACKAGE) -> bool:
49
47
  return installed != available
50
48
 
51
49
 
50
+ @run_once
52
51
  def lookup_latest_version(package=PACKAGE):
53
52
  """
54
53
  Retrieve the latest version of the package from PyPI.
@@ -62,19 +61,18 @@ def lookup_latest_version(package=PACKAGE):
62
61
  The latest version string from PyPI or None if retrieval fails
63
62
  """
64
63
  # Fetch the latest version from PyPI
65
- global LATEST_VERSION # pylint: disable=global-statement
66
- if not LATEST_VERSION:
67
- no_cache = f'?cachebust={randint(0, 6969)}'
68
- url = f"https://pypi.org/pypi/{package}/json{no_cache}"
69
- try:
70
- response = requests.get(url, timeout=5)
71
- response.raise_for_status() # Raise an exception for HTTP errors
72
- LATEST_VERSION = response.json()['info']['version']
73
- log.debug(f'Latest pypi version: {LATEST_VERSION}')
74
- except BaseException:
75
- log.debug(traceback.format_exc())
76
- log.warning('Unable to fetch package version from PyPi')
77
- return LATEST_VERSION
64
+ no_cache = f'?cachebust={randint(0, 6969)}'
65
+ url = f"https://pypi.org/pypi/{package}/json{no_cache}"
66
+ try:
67
+ response = requests.get(url, timeout=3)
68
+ response.raise_for_status() # Raise an exception for HTTP errors
69
+ latest_version = response.json()['info']['version']
70
+ log.debug(f'Latest pypi version: {latest_version}')
71
+ return latest_version
72
+ except BaseException:
73
+ log.debug(traceback.format_exc())
74
+ log.warning('Unable to fetch package version from PyPi')
75
+ return None
78
76
 
79
77
 
80
78
  def get_installed_version(package=PACKAGE):
@@ -0,0 +1,4 @@
1
+ {
2
+ "443": "https",
3
+ "80": "http"
4
+ }