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.
- lanscape/__init__.py +8 -4
- lanscape/{libraries → core}/app_scope.py +21 -3
- lanscape/core/decorators.py +231 -0
- lanscape/{libraries → core}/device_alive.py +83 -16
- lanscape/{libraries → core}/ip_parser.py +2 -26
- lanscape/{libraries → core}/net_tools.py +209 -66
- lanscape/{libraries → core}/runtime_args.py +6 -0
- lanscape/{libraries → core}/scan_config.py +103 -5
- lanscape/core/service_scan.py +222 -0
- lanscape/{libraries → core}/subnet_scan.py +30 -14
- lanscape/{libraries → core}/version_manager.py +15 -17
- lanscape/resources/ports/test_port_list_scan.json +4 -0
- lanscape/resources/services/definitions.jsonc +576 -400
- lanscape/ui/app.py +17 -5
- lanscape/ui/blueprints/__init__.py +1 -1
- lanscape/ui/blueprints/api/port.py +15 -1
- lanscape/ui/blueprints/api/scan.py +1 -1
- lanscape/ui/blueprints/api/tools.py +4 -4
- lanscape/ui/blueprints/web/routes.py +29 -2
- lanscape/ui/main.py +46 -19
- lanscape/ui/shutdown_handler.py +2 -2
- lanscape/ui/static/css/style.css +186 -20
- lanscape/ui/static/js/core.js +14 -0
- lanscape/ui/static/js/main.js +30 -2
- lanscape/ui/static/js/quietReload.js +3 -0
- lanscape/ui/static/js/scan-config.js +56 -6
- lanscape/ui/templates/base.html +6 -8
- lanscape/ui/templates/core/head.html +1 -1
- lanscape/ui/templates/info.html +20 -5
- lanscape/ui/templates/main.html +33 -36
- lanscape/ui/templates/scan/config.html +214 -176
- lanscape/ui/templates/scan/device-detail.html +111 -0
- lanscape/ui/templates/scan/ip-table-row.html +17 -83
- lanscape/ui/templates/scan/ip-table.html +5 -5
- lanscape/ui/ws/__init__.py +31 -0
- lanscape/ui/ws/delta.py +170 -0
- lanscape/ui/ws/handlers/__init__.py +20 -0
- lanscape/ui/ws/handlers/base.py +145 -0
- lanscape/ui/ws/handlers/port.py +184 -0
- lanscape/ui/ws/handlers/scan.py +352 -0
- lanscape/ui/ws/handlers/tools.py +145 -0
- lanscape/ui/ws/protocol.py +86 -0
- lanscape/ui/ws/server.py +375 -0
- {lanscape-1.3.8a1.dist-info → lanscape-2.4.0a2.dist-info}/METADATA +18 -3
- lanscape-2.4.0a2.dist-info/RECORD +85 -0
- {lanscape-1.3.8a1.dist-info → lanscape-2.4.0a2.dist-info}/WHEEL +1 -1
- lanscape-2.4.0a2.dist-info/entry_points.txt +2 -0
- lanscape/libraries/decorators.py +0 -170
- lanscape/libraries/service_scan.py +0 -50
- lanscape/libraries/web_browser.py +0 -210
- lanscape-1.3.8a1.dist-info/RECORD +0 -74
- /lanscape/{libraries → core}/__init__.py +0 -0
- /lanscape/{libraries → core}/errors.py +0 -0
- /lanscape/{libraries → core}/logger.py +0 -0
- /lanscape/{libraries → core}/mac_lookup.py +0 -0
- /lanscape/{libraries → core}/port_manager.py +0 -0
- {lanscape-1.3.8a1.dist-info → lanscape-2.4.0a2.dist-info}/licenses/LICENSE +0 -0
- {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.
|
|
24
|
-
from lanscape.
|
|
25
|
-
from lanscape.
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
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')
|
|
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(
|
|
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
|
-
|
|
306
|
+
float: Runtime in seconds
|
|
298
307
|
"""
|
|
299
308
|
if self.scan.running:
|
|
300
|
-
return
|
|
301
|
-
return
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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):
|