lanscape 1.3.2a7__tar.gz → 1.3.2a9__tar.gz

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 (79) hide show
  1. {lanscape-1.3.2a7/lanscape.egg-info → lanscape-1.3.2a9}/PKG-INFO +1 -1
  2. lanscape-1.3.2a9/lanscape/libraries/__init__.py +0 -0
  3. lanscape-1.3.2a9/lanscape/libraries/app_scope.py +75 -0
  4. lanscape-1.3.2a9/lanscape/libraries/decorators.py +153 -0
  5. lanscape-1.3.2a9/lanscape/libraries/errors.py +32 -0
  6. lanscape-1.3.2a9/lanscape/libraries/ip_parser.py +69 -0
  7. lanscape-1.3.2a9/lanscape/libraries/logger.py +45 -0
  8. lanscape-1.3.2a9/lanscape/libraries/mac_lookup.py +102 -0
  9. lanscape-1.3.2a9/lanscape/libraries/net_tools.py +516 -0
  10. lanscape-1.3.2a9/lanscape/libraries/port_manager.py +67 -0
  11. lanscape-1.3.2a9/lanscape/libraries/runtime_args.py +54 -0
  12. lanscape-1.3.2a9/lanscape/libraries/scan_config.py +97 -0
  13. lanscape-1.3.2a9/lanscape/libraries/service_scan.py +50 -0
  14. lanscape-1.3.2a9/lanscape/libraries/subnet_scan.py +338 -0
  15. lanscape-1.3.2a9/lanscape/libraries/version_manager.py +56 -0
  16. lanscape-1.3.2a9/lanscape/libraries/web_browser.py +142 -0
  17. lanscape-1.3.2a9/lanscape/resources/mac_addresses/convert_csv.py +30 -0
  18. lanscape-1.3.2a9/lanscape/resources/ports/convert_csv.py +30 -0
  19. lanscape-1.3.2a9/lanscape/ui/app.py +128 -0
  20. lanscape-1.3.2a9/lanscape/ui/blueprints/__init__.py +7 -0
  21. lanscape-1.3.2a9/lanscape/ui/blueprints/api/__init__.py +3 -0
  22. lanscape-1.3.2a9/lanscape/ui/blueprints/api/port.py +33 -0
  23. lanscape-1.3.2a9/lanscape/ui/blueprints/api/scan.py +75 -0
  24. lanscape-1.3.2a9/lanscape/ui/blueprints/api/tools.py +36 -0
  25. lanscape-1.3.2a9/lanscape/ui/blueprints/web/__init__.py +3 -0
  26. lanscape-1.3.2a9/lanscape/ui/blueprints/web/routes.py +78 -0
  27. lanscape-1.3.2a9/lanscape/ui/main.py +137 -0
  28. {lanscape-1.3.2a7 → lanscape-1.3.2a9/lanscape.egg-info}/PKG-INFO +1 -1
  29. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape.egg-info/SOURCES.txt +26 -0
  30. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/pyproject.toml +7 -2
  31. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/LICENSE +0 -0
  32. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/MANIFEST.in +0 -0
  33. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/README.md +0 -0
  34. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/__init__.py +0 -0
  35. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/__main__.py +0 -0
  36. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/resources/mac_addresses/mac_db.json +0 -0
  37. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/resources/ports/full.json +0 -0
  38. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/resources/ports/large.json +0 -0
  39. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/resources/ports/medium.json +0 -0
  40. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/resources/ports/small.json +0 -0
  41. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/resources/services/definitions.jsonc +0 -0
  42. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/css/style.css +0 -0
  43. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/img/ico/android-chrome-192x192.png +0 -0
  44. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/img/ico/android-chrome-512x512.png +0 -0
  45. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/img/ico/apple-touch-icon.png +0 -0
  46. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/img/ico/favicon-16x16.png +0 -0
  47. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/img/ico/favicon-32x32.png +0 -0
  48. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/img/ico/favicon.ico +0 -0
  49. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/img/ico/site.webmanifest +0 -0
  50. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/js/core.js +0 -0
  51. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/js/layout-sizing.js +0 -0
  52. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/js/main.js +0 -0
  53. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/js/on-tab-close.js +0 -0
  54. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/js/quietReload.js +0 -0
  55. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/js/shutdown-server.js +0 -0
  56. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/js/subnet-info.js +0 -0
  57. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/js/subnet-selector.js +0 -0
  58. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/static/lanscape.webmanifest +0 -0
  59. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/templates/base.html +0 -0
  60. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/templates/core/head.html +0 -0
  61. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/templates/core/scripts.html +0 -0
  62. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/templates/error.html +0 -0
  63. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/templates/info.html +0 -0
  64. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/templates/main.html +0 -0
  65. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/templates/scan/export.html +0 -0
  66. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/templates/scan/ip-table-row.html +0 -0
  67. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/templates/scan/ip-table.html +0 -0
  68. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/templates/scan/overview.html +0 -0
  69. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/templates/scan/scan-error.html +0 -0
  70. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/templates/scan.html +0 -0
  71. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape/ui/templates/shutdown.html +0 -0
  72. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape.egg-info/dependency_links.txt +0 -0
  73. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape.egg-info/requires.txt +0 -0
  74. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/lanscape.egg-info/top_level.txt +0 -0
  75. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/setup.cfg +0 -0
  76. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/tests/test_api.py +0 -0
  77. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/tests/test_env.py +0 -0
  78. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/tests/test_library.py +0 -0
  79. {lanscape-1.3.2a7 → lanscape-1.3.2a9}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lanscape
3
- Version: 1.3.2a7
3
+ Version: 1.3.2a9
4
4
  Summary: A python based local network scanner
5
5
  Author-email: Michael Dennis <michael@dipduo.com>
6
6
  License-Expression: MIT
File without changes
@@ -0,0 +1,75 @@
1
+
2
+ """
3
+ Resource and environment management utilities for Lanscape.
4
+ """
5
+
6
+ from pathlib import Path
7
+ import json
8
+ import sys
9
+ import re
10
+
11
+
12
+ class ResourceManager:
13
+ """
14
+ A class to manage assets in the resources folder.
15
+ Works locally and if installed based on relative path from this file.
16
+ """
17
+
18
+ def __init__(self, asset_folder: str):
19
+ """Initialize the resource manager with a specific asset folder."""
20
+ self.asset_dir = self._get_resource_path() / asset_folder
21
+
22
+ def list(self):
23
+ """List all asset names in the asset directory."""
24
+ return [p.name for p in self.asset_dir.iterdir()]
25
+
26
+ def get(self, asset_name: str):
27
+ """Get the content of an asset as a string."""
28
+ with open(self.asset_dir / asset_name, 'r', encoding='utf-8') as f:
29
+ return f.read()
30
+
31
+ def get_json(self, asset_name: str):
32
+ """Get the content of an asset as a JSON object."""
33
+ return json.loads(self.get(asset_name))
34
+
35
+ def get_jsonc(self, asset_name: str):
36
+ """Get JSON content with comments removed."""
37
+ content = self.get(asset_name)
38
+ cleaned_content = re.sub(r'//.*', '', content)
39
+ return json.loads(cleaned_content)
40
+
41
+ def update(self, asset_name: str, content: str):
42
+ """Update the content of an existing asset."""
43
+ with open(self.asset_dir / asset_name, 'w', encoding='utf-8') as f:
44
+ f.write(content)
45
+
46
+ def create(self, asset_name: str, content: str):
47
+ """Create a new asset with the given content."""
48
+ if (self.asset_dir / asset_name).exists():
49
+ raise FileExistsError(f"File {asset_name} already exists")
50
+ with open(self.asset_dir / asset_name, 'w', encoding='utf-8') as f:
51
+ f.write(content)
52
+
53
+ def delete(self, asset_name: str):
54
+ """Delete an asset from the asset directory."""
55
+ (self.asset_dir / asset_name).unlink()
56
+
57
+ def _get_resource_path(self) -> Path:
58
+ """Get the path to the resources directory."""
59
+ base_dir = Path(__file__).parent.parent
60
+ resource_dir = base_dir / "resources"
61
+ return resource_dir
62
+
63
+
64
+ def is_local_run() -> bool:
65
+ """
66
+ Determine if the code is running locally or as an installed PyPI package.
67
+ Returns True if running locally, False if installed as a package.
68
+ """
69
+ module_path = Path(__file__).parent
70
+
71
+ package_folders = ["site-packages", "dist-packages"]
72
+ parts = [part in module_path.parts for part in package_folders]
73
+ if any(parts):
74
+ return False
75
+ return True # Installed package
@@ -0,0 +1,153 @@
1
+
2
+ """
3
+ Decorators and job tracking utilities for Lanscape.
4
+ """
5
+
6
+ from time import time
7
+ from dataclasses import dataclass, field
8
+ from typing import DefaultDict
9
+ from collections import defaultdict
10
+ import inspect
11
+ import functools
12
+ import concurrent.futures
13
+ from tabulate import tabulate
14
+
15
+
16
+ @dataclass
17
+ class JobStats:
18
+ """
19
+ Tracks statistics for job execution, including running, finished, and timing data.
20
+ """
21
+ running: DefaultDict[str, int] = field(default_factory=lambda: defaultdict(int))
22
+ finished: DefaultDict[str, int] = field(default_factory=lambda: defaultdict(int))
23
+ timing: DefaultDict[str, float] = field(default_factory=lambda: defaultdict(float))
24
+
25
+ def __str__(self):
26
+ """Return a formatted string representation of the job statistics."""
27
+ data = [
28
+ [
29
+ name,
30
+ self.running.get(name, 0),
31
+ self.finished.get(name, 0),
32
+ self.timing.get(name, 0.0)
33
+ ]
34
+ for name in set(self.running) | set(self.finished)
35
+ ]
36
+ headers = ["Function", "Running", "Finished", "Avg Time (s)"]
37
+ return tabulate(
38
+ data,
39
+ headers=headers,
40
+ tablefmt="grid"
41
+ )
42
+
43
+
44
+ class JobStatsMixin: # pylint: disable=too-few-public-methods
45
+ """
46
+ Singleton mixin that provides shared job_stats property across all instances.
47
+ """
48
+ _job_stats = None
49
+
50
+ @property
51
+ def job_stats(self):
52
+ """Return the shared JobStats instance."""
53
+ if JobStatsMixin._job_stats is None:
54
+ JobStatsMixin._job_stats = JobStats()
55
+ return JobStatsMixin._job_stats
56
+
57
+
58
+ def job_tracker(func):
59
+ """
60
+ Decorator to track job statistics for a method, including running count, finished count, and average timing.
61
+ """
62
+ def get_fxn_src_name(func, first_arg) -> str:
63
+ """
64
+ Return the function name with the class name prepended if available.
65
+ """
66
+ qual_parts = func.__qualname__.split(".")
67
+ cls_name = qual_parts[-2] if len(qual_parts) > 1 else None
68
+ cls_obj = None # resolved lazily
69
+ if cls_obj is None and cls_name:
70
+ mod = inspect.getmodule(func)
71
+ cls_obj = getattr(mod, cls_name, None)
72
+ if cls_obj and first_arg is not None:
73
+ if (first_arg is cls_obj or isinstance(first_arg, cls_obj)):
74
+ return f"{cls_name}.{func.__name__}"
75
+ return func.__name__
76
+
77
+ def wrapper(*args, **kwargs):
78
+ """Wrap the function to update job statistics before and after execution."""
79
+ class_instance = args[0]
80
+ job_stats = class_instance.job_stats
81
+ fxn = get_fxn_src_name(
82
+ func,
83
+ class_instance
84
+ )
85
+
86
+ # Increment running counter and track execution time
87
+ job_stats.running[fxn] += 1
88
+ start = time()
89
+
90
+ result = func(*args, **kwargs) # Execute the wrapped function
91
+
92
+ # Update statistics after function execution
93
+ elapsed = time() - start
94
+ job_stats.running[fxn] -= 1
95
+ job_stats.finished[fxn] += 1
96
+
97
+ # Calculate the new average timing for the function
98
+ job_stats.timing[fxn] = round(
99
+ ((job_stats.finished[fxn] - 1) * job_stats.timing[fxn] + elapsed)
100
+ / job_stats.finished[fxn],
101
+ 4
102
+ )
103
+
104
+ # Clean up if no more running instances of this function
105
+ if job_stats.running[fxn] == 0:
106
+ job_stats.running.pop(fxn)
107
+
108
+ return result
109
+
110
+ return wrapper
111
+
112
+
113
+ def terminator(func):
114
+ """
115
+ Decorator designed specifically for the SubnetScanner class, helps facilitate termination of a job.
116
+ """
117
+ def wrapper(*args, **kwargs):
118
+ """Wrap the function to check if the scan is running before execution."""
119
+ scan = args[0] # aka self
120
+ if not scan.running:
121
+ return None
122
+ return func(*args, **kwargs)
123
+
124
+ return wrapper
125
+
126
+
127
+ def timeout_enforcer(timeout: int, raise_on_timeout: bool = True):
128
+ """
129
+ Decorator to enforce a timeout on a function.
130
+
131
+ Args:
132
+ timeout (int): Timeout length in seconds.
133
+ raise_on_timeout (bool): Whether to raise an exception if the timeout is exceeded.
134
+ """
135
+ def decorator(func):
136
+ @functools.wraps(func)
137
+ def wrapper(*args, **kwargs):
138
+ """Wrap the function to enforce a timeout on its execution."""
139
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
140
+ future = executor.submit(func, *args, **kwargs)
141
+ try:
142
+ return future.result(
143
+ timeout=timeout
144
+ )
145
+ except concurrent.futures.TimeoutError as exc:
146
+ if raise_on_timeout:
147
+ raise TimeoutError(
148
+ f"Function '{func.__name__}' exceeded timeout of "
149
+ f"{timeout} seconds."
150
+ ) from exc
151
+ return None # Return None if not raising an exception
152
+ return wrapper
153
+ return decorator
@@ -0,0 +1,32 @@
1
+
2
+
3
+ class SubnetTooLargeError(Exception):
4
+ """Custom exception raised when the subnet size exceeds the allowed limit."""
5
+
6
+ def __init__(self, subnet):
7
+ self.subnet = subnet
8
+ super().__init__(f"Subnet {subnet} exceeds the limit of IP addresses.")
9
+
10
+
11
+ class SubnetScanTerminationFailure(Exception):
12
+ def __init__(self, running_threads):
13
+ super().__init__(
14
+ f'Unable to terminate active threads: {running_threads}')
15
+
16
+
17
+ class DeviceError(Exception):
18
+ def __init__(self, e: Exception):
19
+ self.base: Exception = e
20
+ self.method = self._attempt_extract_method()
21
+
22
+ def _attempt_extract_method(self):
23
+ try:
24
+ tb = self.base.__traceback__
25
+ frame = tb.tb_frame
26
+ return frame.f_code.co_name
27
+ except Exception as e:
28
+ print(e)
29
+ return 'unknown'
30
+
31
+ def __str__(self):
32
+ return f'Error(source={self.method}, msg={self.base})'
@@ -0,0 +1,69 @@
1
+ import ipaddress
2
+ from .errors import SubnetTooLargeError
3
+ import re
4
+
5
+ MAX_IPS_ALLOWED = 100000
6
+
7
+
8
+ def parse_ip_input(ip_input):
9
+ # Split input on commas for multiple entries
10
+ entries = [entry.strip() for entry in ip_input.split(',')]
11
+ ip_ranges = []
12
+
13
+ for entry in entries:
14
+ # Handle CIDR notation or IP/32
15
+ if '/' in entry:
16
+ net = ipaddress.IPv4Network(entry, strict=False)
17
+ if net.num_addresses > MAX_IPS_ALLOWED:
18
+ raise SubnetTooLargeError(ip_input)
19
+ for ip in net.hosts():
20
+ ip_ranges.append(ip)
21
+
22
+ # Handle IP range (e.g., 10.0.0.15-10.0.0.25)
23
+ elif '-' in entry:
24
+ ip_ranges += parse_ip_range(entry)
25
+
26
+ # Handle shorthand IP range (e.g., 10.0.9.1-253)
27
+ elif re.search(r'\d+\-\d+', entry):
28
+ ip_ranges += parse_shorthand_ip_range(entry)
29
+
30
+ # If no CIDR or range, assume a single IP
31
+ else:
32
+ ip_ranges.append(ipaddress.IPv4Address(entry))
33
+ if len(ip_ranges) > MAX_IPS_ALLOWED:
34
+ raise SubnetTooLargeError(ip_input)
35
+ return ip_ranges
36
+
37
+
38
+ def get_address_count(subnet: str):
39
+ try:
40
+ net = ipaddress.IPv4Network(subnet, strict=False)
41
+ return net.num_addresses
42
+ except BaseException:
43
+ return 0
44
+
45
+
46
+ def parse_ip_range(entry):
47
+ start_ip, end_ip = entry.split('-')
48
+ start_ip = ipaddress.IPv4Address(start_ip.strip())
49
+
50
+ # Handle case where the second part is a partial IP (e.g., '253')
51
+ if '.' not in end_ip:
52
+ end_ip = start_ip.exploded.rsplit('.', 1)[0] + '.' + end_ip.strip()
53
+
54
+ end_ip = ipaddress.IPv4Address(end_ip.strip())
55
+ return list(ip_range_to_list(start_ip, end_ip))
56
+
57
+
58
+ def parse_shorthand_ip_range(entry):
59
+ start_ip, end_part = entry.split('-')
60
+ start_ip = ipaddress.IPv4Address(start_ip.strip())
61
+ end_ip = start_ip.exploded.rsplit('.', 1)[0] + '.' + end_part.strip()
62
+
63
+ return list(ip_range_to_list(start_ip, ipaddress.IPv4Address(end_ip)))
64
+
65
+
66
+ def ip_range_to_list(start_ip, end_ip):
67
+ # Yield the range of IPs
68
+ for ip_int in range(int(start_ip), int(end_ip) + 1):
69
+ yield ipaddress.IPv4Address(ip_int)
@@ -0,0 +1,45 @@
1
+ import logging
2
+ from logging.handlers import RotatingFileHandler
3
+ import click
4
+
5
+
6
+ def configure_logging(loglevel: str, logfile: bool, flask_logging: bool = False) -> None:
7
+ numeric_level = getattr(logging, loglevel.upper(), None)
8
+ if not isinstance(numeric_level, int):
9
+ raise ValueError(f'Invalid log level: {loglevel}')
10
+
11
+ logging.basicConfig(level=numeric_level,
12
+ format='[%(name)s] %(levelname)s - %(message)s')
13
+
14
+ # flask spams too much on info
15
+ if not flask_logging:
16
+ disable_flask_logging()
17
+
18
+ if logfile:
19
+ handler = RotatingFileHandler(
20
+ 'lanscape.log', maxBytes=100000, backupCount=3)
21
+ handler.setLevel(numeric_level)
22
+ formatter = logging.Formatter(
23
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
24
+ handler.setFormatter(formatter)
25
+ logging.getLogger().addHandler(handler)
26
+ else:
27
+ # For console, it defaults to basicConfig
28
+ pass
29
+
30
+
31
+ def disable_flask_logging() -> None:
32
+
33
+ def override_click_logging():
34
+ def secho(text, file=None, nl=None, err=None, color=None, **styles):
35
+ pass
36
+
37
+ def echo(text, file=None, nl=None, err=None, color=None, **styles):
38
+ pass
39
+
40
+ click.echo = echo
41
+ click.secho = secho
42
+ werkzeug_log = logging.getLogger('werkzeug')
43
+ werkzeug_log.setLevel(logging.ERROR)
44
+
45
+ override_click_logging()
@@ -0,0 +1,102 @@
1
+ import re
2
+ import logging
3
+ import platform
4
+ import subprocess
5
+ from typing import List, Optional
6
+ from scapy.sendrecv import srp
7
+ from scapy.layers.l2 import ARP, Ether
8
+ from .app_scope import ResourceManager
9
+ from .decorators import job_tracker, JobStatsMixin
10
+ from .errors import DeviceError
11
+
12
+
13
+ log = logging.getLogger('MacLookup')
14
+
15
+
16
+ class MacLookup:
17
+ """High-level MAC address lookup service."""
18
+
19
+ def __init__(self):
20
+ self._db = ResourceManager('mac_addresses').get_json('mac_db.json')
21
+ self._resolver = MacResolver()
22
+
23
+ def lookup_vendor(self, mac: str) -> Optional[str]:
24
+ """
25
+ Lookup a MAC address in the database and return the vendor name.
26
+ """
27
+ if mac:
28
+ for m in self._db:
29
+ if mac.upper().startswith(str(m).upper()):
30
+ return self._db[m]
31
+ return None
32
+
33
+ def resolve_mac_addresses(self, ip: str) -> List[str]:
34
+ """
35
+ Get MAC addresses for an IP address using available methods.
36
+ """
37
+ return self._resolver.get_macs(ip)
38
+
39
+
40
+ class MacResolver(JobStatsMixin):
41
+ """Handles MAC address resolution using various methods."""
42
+
43
+ def __init__(self):
44
+ super().__init__()
45
+ self.caught_errors: List[DeviceError] = []
46
+
47
+ def get_macs(self, ip: str) -> List[str]:
48
+ """Try to get the MAC address using Scapy, fallback to ARP if it fails."""
49
+ if mac := self._get_mac_by_scapy(ip):
50
+ log.debug(f"Used Scapy to resolve ip {ip} to mac {mac}")
51
+ return mac
52
+ arp = self._get_mac_by_arp(ip)
53
+ log.debug(f"Used ARP to resolve ip {ip} to mac {arp}")
54
+ return arp
55
+
56
+ @job_tracker
57
+ def _get_mac_by_arp(self, ip: str) -> List[str]:
58
+ """Retrieve the last MAC address instance using the ARP command."""
59
+ try:
60
+ # Use the appropriate ARP command based on the platform
61
+ cmd = f"arp -a {ip}" if platform.system() == "Windows" else f"arp {ip}"
62
+
63
+ # Execute the ARP command and decode the output
64
+ output = subprocess.check_output(
65
+ cmd, shell=True
66
+ ).decode().replace('-', ':')
67
+
68
+ macs = re.findall(r'..:..:..:..:..:..', output)
69
+ # found that typically last mac is the correct one
70
+ return macs
71
+ except Exception as e:
72
+ self.caught_errors.append(DeviceError(e))
73
+ return []
74
+
75
+ @job_tracker
76
+ def _get_mac_by_scapy(self, ip: str) -> List[str]:
77
+ """Retrieve the MAC address using the Scapy library."""
78
+ try:
79
+ # Construct and send an ARP request
80
+ arp_request = ARP(pdst=ip)
81
+ broadcast = Ether(dst="ff:ff:ff:ff:ff:ff")
82
+ packet = broadcast / arp_request
83
+
84
+ # Send the packet and wait for a response
85
+ result = srp(packet, timeout=1, verbose=0)[0]
86
+
87
+ # Extract the MAC addresses from the response
88
+ return [res[1].hwsrc for res in result]
89
+ except Exception as e:
90
+ self.caught_errors.append(DeviceError(e))
91
+ return []
92
+
93
+
94
+ # Backward compatibility functions
95
+ def lookup_mac(mac: str) -> Optional[str]:
96
+ """Backward compatibility function for MAC vendor lookup."""
97
+ return MacLookup().lookup_vendor(mac)
98
+
99
+
100
+ def get_macs(ip: str) -> List[str]:
101
+ """Backward compatibility function for MAC resolution."""
102
+ return MacResolver().get_macs(ip)