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