lanscape 1.4.4__py3-none-any.whl → 2.0.0a1__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.
Files changed (46) hide show
  1. lanscape/__init__.py +9 -4
  2. lanscape/__main__.py +1 -0
  3. lanscape/{libraries → core}/app_scope.py +22 -3
  4. lanscape/{libraries → core}/decorators.py +88 -52
  5. lanscape/{libraries → core}/device_alive.py +4 -3
  6. lanscape/{libraries → core}/errors.py +1 -0
  7. lanscape/{libraries → core}/ip_parser.py +2 -1
  8. lanscape/{libraries → core}/logger.py +1 -0
  9. lanscape/{libraries → core}/mac_lookup.py +1 -0
  10. lanscape/{libraries → core}/net_tools.py +140 -46
  11. lanscape/{libraries → core}/port_manager.py +1 -0
  12. lanscape/{libraries → core}/runtime_args.py +1 -0
  13. lanscape/{libraries → core}/scan_config.py +104 -5
  14. lanscape/core/service_scan.py +205 -0
  15. lanscape/{libraries → core}/subnet_scan.py +19 -11
  16. lanscape/{libraries → core}/version_manager.py +3 -2
  17. lanscape/{libraries → core}/web_browser.py +1 -0
  18. lanscape/resources/mac_addresses/convert_csv.py +1 -0
  19. lanscape/resources/ports/convert_csv.py +1 -0
  20. lanscape/resources/services/definitions.jsonc +576 -400
  21. lanscape/ui/app.py +5 -4
  22. lanscape/ui/blueprints/__init__.py +2 -1
  23. lanscape/ui/blueprints/api/__init__.py +1 -0
  24. lanscape/ui/blueprints/api/port.py +2 -1
  25. lanscape/ui/blueprints/api/scan.py +2 -1
  26. lanscape/ui/blueprints/api/tools.py +5 -4
  27. lanscape/ui/blueprints/web/__init__.py +1 -0
  28. lanscape/ui/blueprints/web/routes.py +30 -2
  29. lanscape/ui/main.py +5 -4
  30. lanscape/ui/shutdown_handler.py +2 -1
  31. lanscape/ui/static/css/style.css +145 -2
  32. lanscape/ui/static/js/main.js +30 -2
  33. lanscape/ui/static/js/scan-config.js +39 -0
  34. lanscape/ui/templates/scan/config.html +43 -0
  35. lanscape/ui/templates/scan/device-detail.html +111 -0
  36. lanscape/ui/templates/scan/ip-table-row.html +12 -78
  37. lanscape/ui/templates/scan/ip-table.html +1 -1
  38. {lanscape-1.4.4.dist-info → lanscape-2.0.0a1.dist-info}/METADATA +7 -2
  39. lanscape-2.0.0a1.dist-info/RECORD +76 -0
  40. lanscape-2.0.0a1.dist-info/entry_points.txt +2 -0
  41. lanscape/libraries/service_scan.py +0 -50
  42. lanscape-1.4.4.dist-info/RECORD +0 -74
  43. /lanscape/{libraries → core}/__init__.py +0 -0
  44. {lanscape-1.4.4.dist-info → lanscape-2.0.0a1.dist-info}/WHEEL +0 -0
  45. {lanscape-1.4.4.dist-info → lanscape-2.0.0a1.dist-info}/licenses/LICENSE +0 -0
  46. {lanscape-1.4.4.dist-info → lanscape-2.0.0a1.dist-info}/top_level.txt +0 -0
@@ -11,8 +11,8 @@ from enum import Enum
11
11
 
12
12
  from pydantic import BaseModel, Field
13
13
 
14
- from lanscape.libraries.port_manager import PortManager
15
- from lanscape.libraries.ip_parser import parse_ip_input
14
+ from lanscape.core.port_manager import PortManager
15
+ from lanscape.core.ip_parser import parse_ip_input
16
16
 
17
17
 
18
18
  class PingConfig(BaseModel):
@@ -154,6 +154,87 @@ class PokeConfig(BaseModel):
154
154
  return self.model_dump()
155
155
 
156
156
 
157
+ class ServiceScanStrategy(Enum):
158
+ """
159
+ Enumeration of strategies for service scanning on open ports.
160
+
161
+ LAZY: Several common probes to see if we can identify the service.
162
+ BASIC: Common probes plus probes correlated to the port number.
163
+ AGGRESSIVE: All known probes in parallel to try to elicit a response.
164
+ """
165
+ LAZY = 'LAZY'
166
+ BASIC = 'BASIC'
167
+ AGGRESSIVE = 'AGGRESSIVE'
168
+
169
+
170
+ class ServiceScanConfig(BaseModel):
171
+ """
172
+ Configuration for service scanning on open ports.
173
+ """
174
+ timeout: float = 5.0
175
+ lookup_type: ServiceScanStrategy = ServiceScanStrategy.BASIC
176
+ max_concurrent_probes: int = 10
177
+
178
+ @classmethod
179
+ def from_dict(cls, data: dict) -> 'ServiceScanConfig':
180
+ """
181
+ Create a ServiceScanConfig instance from a dictionary.
182
+
183
+ Args:
184
+ data: Dictionary containing ServiceScanConfig parameters
185
+
186
+ Returns:
187
+ A new ServiceScanConfig instance with the provided settings
188
+ """
189
+ return cls.model_validate(data)
190
+
191
+ def to_dict(self) -> dict:
192
+ """
193
+ Convert the ServiceScanConfig instance to a dictionary.
194
+
195
+ Returns:
196
+ Dictionary representation of the ServiceScanConfig
197
+ """
198
+ return self.model_dump()
199
+
200
+ def __str__(self):
201
+ return f'ServiceScanCfg(timeout={self.timeout})'
202
+
203
+
204
+ class PortScanConfig(BaseModel):
205
+ """
206
+ Configuration for port scanning.
207
+ """
208
+ timeout: float = 1.0
209
+ retries: int = 0
210
+ retry_delay: float = 0.1
211
+
212
+ @classmethod
213
+ def from_dict(cls, data: dict) -> 'PortScanConfig':
214
+ """
215
+ Create a PortScanConfig instance from a dictionary.
216
+
217
+ Args:
218
+ data: Dictionary containing PortScanConfig parameters
219
+
220
+ Returns:
221
+ A new PortScanConfig instance with the provided settings
222
+ """
223
+ return cls.model_validate(data)
224
+
225
+ def to_dict(self) -> dict:
226
+ """
227
+ Convert the PortScanConfig instance to a dictionary.
228
+
229
+ Returns:
230
+ Dictionary representation of the PortScanConfig
231
+ """
232
+ return self.model_dump()
233
+
234
+ def __str__(self):
235
+ return f'PortScanCfg(timeout={self.timeout}, retry_delay={self.retry_delay})'
236
+
237
+
157
238
  class ScanType(Enum):
158
239
  """
159
240
  Enumeration of supported network scan types.
@@ -184,7 +265,7 @@ class ScanConfig(BaseModel):
184
265
 
185
266
  task_scan_ports: bool = True
186
267
  # below wont run if above false
187
- task_scan_port_services: bool = False # disabling until more stable
268
+ task_scan_port_services: bool = True
188
269
 
189
270
  lookup_type: List[ScanType] = [ScanType.ICMP_THEN_ARP]
190
271
 
@@ -192,6 +273,8 @@ class ScanConfig(BaseModel):
192
273
  arp_config: ArpConfig = Field(default_factory=ArpConfig)
193
274
  poke_config: PokeConfig = Field(default_factory=PokeConfig)
194
275
  arp_cache_config: ArpCacheConfig = Field(default_factory=ArpCacheConfig)
276
+ port_scan_config: PortScanConfig = Field(default_factory=PortScanConfig)
277
+ service_scan_config: ServiceScanConfig = Field(default_factory=ServiceScanConfig)
195
278
 
196
279
  def t_cnt(self, thread_id: str) -> int:
197
280
  """
@@ -259,7 +342,7 @@ DEFAULT_CONFIGS: Dict[str, ScanConfig] = {
259
342
  t_cnt_port_test=64,
260
343
  t_cnt_isalive=64,
261
344
  task_scan_ports=True,
262
- task_scan_port_services=False,
345
+ task_scan_port_services=True,
263
346
  lookup_type=[ScanType.ICMP_THEN_ARP, ScanType.ARP_LOOKUP],
264
347
  arp_config=ArpConfig(
265
348
  attempts=3,
@@ -274,6 +357,16 @@ DEFAULT_CONFIGS: Dict[str, ScanConfig] = {
274
357
  arp_cache_config=ArpCacheConfig(
275
358
  attempts=2,
276
359
  wait_before=0.3
360
+ ),
361
+ port_scan_config=PortScanConfig(
362
+ timeout=2.5,
363
+ retries=1,
364
+ retry_delay=0.2
365
+ ),
366
+ service_scan_config=ServiceScanConfig(
367
+ timeout=8.0,
368
+ lookup_type=ServiceScanStrategy.AGGRESSIVE,
369
+ max_concurrent_probes=5
277
370
  )
278
371
  ),
279
372
  'fast': ScanConfig(
@@ -283,7 +376,7 @@ DEFAULT_CONFIGS: Dict[str, ScanConfig] = {
283
376
  t_cnt_port_test=256,
284
377
  t_cnt_isalive=512,
285
378
  task_scan_ports=True,
286
- task_scan_port_services=False,
379
+ task_scan_port_services=True,
287
380
  lookup_type=[ScanType.POKE_THEN_ARP],
288
381
  arp_config=ArpConfig(
289
382
  attempts=1,
@@ -294,6 +387,12 @@ DEFAULT_CONFIGS: Dict[str, ScanConfig] = {
294
387
  ping_count=1,
295
388
  timeout=0.5,
296
389
  retry_delay=0.25
390
+ ),
391
+ service_scan_config=ServiceScanConfig(
392
+ timeout=2.0,
393
+ lookup_type=ServiceScanStrategy.LAZY,
394
+ max_concurrent_probes=15
297
395
  )
298
396
  )
299
397
  }
398
+
@@ -0,0 +1,205 @@
1
+ """Service scanning module for identifying services running on network ports.
2
+ """
3
+
4
+ from typing import Optional, Union
5
+ import sys
6
+ import asyncio
7
+ import logging
8
+ import traceback
9
+
10
+ from lanscape.core.app_scope import ResourceManager
11
+ from lanscape.core.scan_config import ServiceScanConfig, ServiceScanStrategy
12
+
13
+ log = logging.getLogger('ServiceScan')
14
+ SERVICES = ResourceManager('services').get_jsonc('definitions.jsonc')
15
+
16
+ # skip printer ports because they cause blank pages to be printed
17
+ PRINTER_PORTS = [9100, 631]
18
+
19
+
20
+ async def _try_probe(
21
+ ip: str,
22
+ port: int,
23
+ payload: Optional[Union[str, bytes]] = None,
24
+ *,
25
+ timeout: float = 5.0,
26
+ read_len: int = 1024,
27
+ ) -> Optional[str]:
28
+ """
29
+ Open a connection, optionally send a payload, and read a single response chunk.
30
+ Returns the decoded response string or None.
31
+ """
32
+ try:
33
+ reader, writer = await asyncio.wait_for(
34
+ asyncio.open_connection(ip, port), timeout=timeout
35
+ )
36
+ try:
37
+ if payload is not None:
38
+ data = payload if isinstance(
39
+ payload, (bytes, bytearray)) else str(payload).encode(
40
+ "utf-8", errors="ignore")
41
+ writer.write(data)
42
+ await writer.drain()
43
+ try:
44
+ response = await asyncio.wait_for(reader.read(read_len), timeout=timeout / 2)
45
+ except asyncio.TimeoutError:
46
+ response = b""
47
+ resp_str = response.decode("utf-8", errors="ignore") if response else ""
48
+ return resp_str if resp_str else None
49
+ finally:
50
+ # Guarded close to avoid surfacing connection-lost noise
51
+ try:
52
+ writer.close()
53
+ except Exception:
54
+ pass
55
+ try:
56
+ await asyncio.wait_for(writer.wait_closed(), timeout=0.5)
57
+ except Exception:
58
+ pass
59
+ except Exception as e:
60
+ # Suppress common/expected network errors that simply indicate no useful banner
61
+ expected_types = (ConnectionResetError, ConnectionRefusedError, TimeoutError, OSError)
62
+ expected_errnos = {10054, 10061, 10060} # reset, refused, timeout (Win specific)
63
+ eno = getattr(e, 'errno', None)
64
+ if isinstance(e, expected_types) and (eno in expected_errnos or eno is None):
65
+ return None
66
+ log.debug(f"Probe error on {ip}:{port} - {repr(e)}")
67
+ return None
68
+
69
+
70
+ async def _multi_probe_generic(
71
+ ip: str, port: int, cfg: ServiceScanConfig
72
+ ) -> Optional[str]:
73
+ """
74
+ Run a small set of generic probes in parallel and return the first non-empty response.
75
+ """
76
+ probes = get_port_probes(port, cfg.lookup_type)
77
+
78
+ semaphore = asyncio.Semaphore(cfg.max_concurrent_probes)
79
+
80
+ async def limited_probe(ip, port, payload, timeout_val):
81
+ async with semaphore:
82
+ return await _try_probe(
83
+ ip, port, payload,
84
+ timeout=timeout_val
85
+ )
86
+
87
+ tasks = [
88
+ asyncio.create_task(
89
+ limited_probe(ip, port, p, cfg.timeout)
90
+ )
91
+ for p in probes
92
+ ]
93
+
94
+ try:
95
+ for fut in asyncio.as_completed(tasks, timeout=cfg.timeout):
96
+ try:
97
+ resp = await fut
98
+ except Exception:
99
+ resp = None
100
+ if resp and resp.strip():
101
+ # Cancel remaining tasks
102
+ for t in tasks:
103
+ if not t.done():
104
+ t.cancel()
105
+ return resp
106
+ except asyncio.TimeoutError:
107
+ pass
108
+ finally:
109
+ # Ensure remaining tasks are cancelled and awaited to suppress warnings
110
+ for t in tasks:
111
+ if not t.done():
112
+ t.cancel()
113
+ await asyncio.gather(*tasks, return_exceptions=True)
114
+
115
+ return None
116
+
117
+
118
+ def get_port_probes(port: int, strategy: ServiceScanStrategy):
119
+ """
120
+ Return a list of probe payloads based on the port and strategy.
121
+ """
122
+ # For now, we use generic probes for all ports.
123
+ # This can be extended to use specific probes per port/service.
124
+
125
+ probes = [
126
+ None, # banner-first protocols (SSH/FTP/SMTP/etc.)
127
+ b"\r\n", # nudge for many line-oriented services
128
+ b"HELP\r\n", # sometimes yields usage/help (SMTP/POP/IMAP-ish)
129
+ b"OPTIONS * HTTP/1.0\r\n\r\n", # elicit Server header without path
130
+ b"HEAD / HTTP/1.0\r\n\r\n", # basic HTTP
131
+ b"QUIT\r\n", # graceful close if understood
132
+ ]
133
+
134
+ if strategy == ServiceScanStrategy.LAZY:
135
+ return probes
136
+
137
+ if strategy == ServiceScanStrategy.BASIC:
138
+ for _, detail in SERVICES.items():
139
+ if port in detail.get("ports", []):
140
+ if probe := detail.get("probe", ''):
141
+ probes.append(probe)
142
+ return probes
143
+
144
+ if strategy == ServiceScanStrategy.AGGRESSIVE:
145
+ for _, detail in SERVICES.items():
146
+ if probe := detail.get("probe", ''):
147
+ probes.append(probe)
148
+ return probes
149
+
150
+ return [None] # Default to banner grab only
151
+
152
+
153
+ def scan_service(ip: str, port: int, cfg: ServiceScanConfig) -> str:
154
+ """
155
+ Synchronous function that attempts to identify the service running on a given port.
156
+ """
157
+
158
+ async def _async_scan_service(
159
+ ip: str, port: int,
160
+ cfg: ServiceScanConfig
161
+ ) -> str:
162
+ if port in PRINTER_PORTS:
163
+ return "Printer"
164
+
165
+ try:
166
+ # Run multiple generic probes concurrently and take first useful response
167
+ response_str = await _multi_probe_generic(ip, port, cfg)
168
+ if not response_str:
169
+ return "Unknown"
170
+
171
+ log.debug(f"Service scan response from {ip}:{port} - {response_str}")
172
+
173
+ # Analyze the response to identify the service
174
+ for service, config in SERVICES.items():
175
+ if any(hint.lower() in response_str.lower() for hint in config.get("hints", [])):
176
+ return service
177
+ except asyncio.TimeoutError:
178
+ log.warning(f"Timeout scanning {ip}:{port}")
179
+ except Exception as e:
180
+ log.error(f"Error scanning {ip}:{port}: {str(e)}")
181
+ log.debug(traceback.format_exc())
182
+ return "Unknown"
183
+
184
+ # Use asyncio.run to execute the asynchronous logic synchronously
185
+ return asyncio.run(_async_scan_service(ip, port, cfg=cfg))
186
+
187
+
188
+ def asyncio_logger_suppression():
189
+ """Suppress the noisy asyncio transport errors since they are expected in service scanning."""
190
+
191
+ # Reduce noisy asyncio transport errors on Windows by switching to Selector policy
192
+ if sys.platform.startswith("win"):
193
+ try:
194
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
195
+ except Exception:
196
+ pass
197
+ # Also tone down asyncio logger noise from transport callbacks
198
+ try:
199
+ logging.getLogger("asyncio").setLevel(logging.WARNING)
200
+ except Exception:
201
+ pass
202
+
203
+
204
+ asyncio_logger_suppression()
205
+
@@ -20,11 +20,11 @@ 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 Device
26
+ from lanscape.core.errors import SubnetScanTerminationFailure
27
+ from lanscape.core.device_alive import is_device_alive
28
28
 
29
29
 
30
30
  class SubnetScanner():
@@ -62,7 +62,10 @@ class SubnetScanner():
62
62
  """
63
63
  self._set_stage('scanning devices')
64
64
  self.running = True
65
- with ThreadPoolExecutor(max_workers=self.cfg.t_cnt('isalive')) as executor:
65
+ with ThreadPoolExecutor(
66
+ max_workers=self.cfg.t_cnt('isalive'),
67
+ thread_name_prefix="DeviceAlive") as executor:
68
+
66
69
  futures = {executor.submit(self._get_host_details, str(
67
70
  ip)): str(ip) for ip in self.subnet}
68
71
  for future in as_completed(futures):
@@ -187,7 +190,7 @@ class SubnetScanner():
187
190
  """
188
191
  Get the MAC address and open ports of the given host.
189
192
  """
190
- device = Device(host)
193
+ device = Device(ip=host)
191
194
  device.alive = self._ping(device)
192
195
  self.results.scanned()
193
196
  if not device.alive:
@@ -199,7 +202,8 @@ class SubnetScanner():
199
202
 
200
203
  @terminator
201
204
  def _scan_network_ports(self):
202
- with ThreadPoolExecutor(max_workers=self.cfg.t_cnt('port_scan')) as executor:
205
+ with ThreadPoolExecutor(max_workers=self.cfg.t_cnt('port_scan'),
206
+ thread_name_prefix="DevicePortScanParent") as executor:
203
207
  futures = {executor.submit(
204
208
  self._scan_ports, device): device for device in self.results.devices}
205
209
  for future in futures:
@@ -210,7 +214,9 @@ class SubnetScanner():
210
214
  def _scan_ports(self, device: Device):
211
215
  self.log.debug(f'[{device.ip}] Initiating port scan')
212
216
  device.stage = 'scanning'
213
- with ThreadPoolExecutor(max_workers=self.cfg.t_cnt('port_test')) as executor:
217
+ with ThreadPoolExecutor(
218
+ max_workers=self.cfg.t_cnt('port_test'),
219
+ thread_name_prefix=f"{device.ip}-PortScan") as executor:
214
220
  futures = {executor.submit(self._test_port, device, int(
215
221
  port)): port for port in self.ports}
216
222
  for future in futures:
@@ -226,9 +232,9 @@ class SubnetScanner():
226
232
  If port open, determine service.
227
233
  Device class handles tracking open ports.
228
234
  """
229
- is_alive = host.test_port(port)
235
+ is_alive = host.test_port(port, self.cfg.port_scan_config)
230
236
  if is_alive and self.cfg.task_scan_port_services:
231
- host.scan_service(port)
237
+ host.scan_service(port, self.cfg.service_scan_config)
232
238
  return is_alive
233
239
 
234
240
  @terminator
@@ -264,6 +270,7 @@ class ScannerResults:
264
270
  # Scan statistics
265
271
  self.devices_total: int = len(list(scan.subnet))
266
272
  self.devices_scanned: int = 0
273
+ self.port_list_length: int = len(scan.ports)
267
274
  self.devices: List[Device] = []
268
275
 
269
276
  # Status tracking
@@ -421,3 +428,4 @@ class ScanManager:
421
428
  t = threading.Thread(target=scan.start)
422
429
  t.start()
423
430
  return t
431
+
@@ -11,8 +11,8 @@ from random import randint
11
11
 
12
12
  import requests
13
13
 
14
- from lanscape.libraries.app_scope import is_local_run
15
- from lanscape.libraries.decorators import run_once
14
+ from lanscape.core.app_scope import is_local_run
15
+ from lanscape.core.decorators import run_once
16
16
 
17
17
  log = logging.getLogger('VersionManager')
18
18
 
@@ -95,3 +95,4 @@ def get_installed_version(package=PACKAGE):
95
95
  log.debug(traceback.format_exc())
96
96
  log.warning(f'Cannot find {package} installation')
97
97
  return LOCAL_VERSION
98
+
@@ -208,3 +208,4 @@ def windows_get_browser_from_registry() -> Optional[str]:
208
208
  system_browser = get_system_default_browser()
209
209
  if system_browser:
210
210
  return extract_executable(system_browser)
211
+
@@ -39,3 +39,4 @@ def csv_to_dict(data):
39
39
 
40
40
 
41
41
  main()
42
+
@@ -38,3 +38,4 @@ def csv_to_dict(data):
38
38
 
39
39
 
40
40
  main()
41
+