lanscape 1.2.10a1__tar.gz → 1.3.0__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 (87) hide show
  1. {lanscape-1.2.10a1/src/lanscape.egg-info → lanscape-1.3.0}/PKG-INFO +12 -3
  2. {lanscape-1.2.10a1 → lanscape-1.3.0}/README.md +11 -2
  3. {lanscape-1.2.10a1 → lanscape-1.3.0}/pyproject.toml +1 -1
  4. lanscape-1.3.0/src/lanscape/libraries/errors.py +29 -0
  5. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/logger.py +2 -2
  6. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/net_tools.py +76 -40
  7. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/runtime_args.py +4 -0
  8. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/subnet_scan.py +25 -14
  9. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/version_manager.py +10 -6
  10. lanscape-1.3.0/src/lanscape/libraries/web_browser.py +124 -0
  11. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/tests/test_api.py +42 -1
  12. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/app.py +17 -9
  13. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/blueprints/api/scan.py +3 -1
  14. lanscape-1.3.0/src/lanscape/ui/blueprints/web/routes.py +74 -0
  15. lanscape-1.3.0/src/lanscape/ui/main.py +134 -0
  16. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/css/style.css +3 -1
  17. lanscape-1.3.0/src/lanscape/ui/static/img/ico/android-chrome-192x192.png +0 -0
  18. lanscape-1.3.0/src/lanscape/ui/static/img/ico/android-chrome-512x512.png +0 -0
  19. lanscape-1.3.0/src/lanscape/ui/static/img/ico/apple-touch-icon.png +0 -0
  20. lanscape-1.3.0/src/lanscape/ui/static/img/ico/favicon-16x16.png +0 -0
  21. lanscape-1.3.0/src/lanscape/ui/static/img/ico/favicon-32x32.png +0 -0
  22. lanscape-1.3.0/src/lanscape/ui/static/img/ico/favicon.ico +0 -0
  23. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/js/main.js +38 -4
  24. lanscape-1.3.0/src/lanscape/ui/static/js/on-tab-close.js +29 -0
  25. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/core/scripts.html +1 -0
  26. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/info.html +2 -2
  27. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/scan/overview.html +3 -2
  28. {lanscape-1.2.10a1 → lanscape-1.3.0/src/lanscape.egg-info}/PKG-INFO +12 -3
  29. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape.egg-info/SOURCES.txt +2 -0
  30. lanscape-1.2.10a1/src/lanscape/libraries/errors.py +0 -12
  31. lanscape-1.2.10a1/src/lanscape/ui/blueprints/web/routes.py +0 -67
  32. lanscape-1.2.10a1/src/lanscape/ui/main.py +0 -98
  33. lanscape-1.2.10a1/src/lanscape/ui/static/img/ico/android-chrome-192x192.png +0 -0
  34. lanscape-1.2.10a1/src/lanscape/ui/static/img/ico/android-chrome-512x512.png +0 -0
  35. lanscape-1.2.10a1/src/lanscape/ui/static/img/ico/apple-touch-icon.png +0 -0
  36. lanscape-1.2.10a1/src/lanscape/ui/static/img/ico/favicon-16x16.png +0 -0
  37. lanscape-1.2.10a1/src/lanscape/ui/static/img/ico/favicon-32x32.png +0 -0
  38. lanscape-1.2.10a1/src/lanscape/ui/static/img/ico/favicon.ico +0 -0
  39. {lanscape-1.2.10a1 → lanscape-1.3.0}/LICENSE +0 -0
  40. {lanscape-1.2.10a1 → lanscape-1.3.0}/MANIFEST.in +0 -0
  41. {lanscape-1.2.10a1 → lanscape-1.3.0}/setup.cfg +0 -0
  42. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/__init__.py +0 -0
  43. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/__main__.py +0 -0
  44. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/app_scope.py +0 -0
  45. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/decorators.py +0 -0
  46. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/ip_parser.py +0 -0
  47. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/mac_lookup.py +0 -0
  48. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/port_manager.py +0 -0
  49. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/service_scan.py +0 -0
  50. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/mac_addresses/convert_csv.py +0 -0
  51. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/mac_addresses/mac_db.json +0 -0
  52. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/ports/convert_csv.py +0 -0
  53. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/ports/full.json +0 -0
  54. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/ports/large.json +0 -0
  55. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/ports/medium.json +0 -0
  56. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/ports/small.json +0 -0
  57. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/services/definitions.jsonc +0 -0
  58. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/tests/__init__.py +0 -0
  59. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/tests/_helpers.py +0 -0
  60. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/tests/test_env.py +0 -0
  61. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/tests/test_library.py +0 -0
  62. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/blueprints/__init__.py +0 -0
  63. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/blueprints/api/__init__.py +0 -0
  64. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/blueprints/api/port.py +0 -0
  65. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/blueprints/api/tools.py +0 -0
  66. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/blueprints/web/__init__.py +0 -0
  67. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/img/ico/site.webmanifest +0 -0
  68. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/js/core.js +0 -0
  69. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/js/layout-sizing.js +0 -0
  70. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/js/quietReload.js +0 -0
  71. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/js/shutdown-server.js +0 -0
  72. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/js/subnet-info.js +0 -0
  73. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/js/subnet-selector.js +0 -0
  74. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/lanscape.webmanifest +0 -0
  75. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/base.html +0 -0
  76. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/core/head.html +0 -0
  77. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/error.html +0 -0
  78. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/main.html +0 -0
  79. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/scan/export.html +0 -0
  80. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/scan/ip-table-row.html +0 -0
  81. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/scan/ip-table.html +0 -0
  82. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/scan/scan-error.html +0 -0
  83. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/scan.html +0 -0
  84. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/shutdown.html +0 -0
  85. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape.egg-info/dependency_links.txt +0 -0
  86. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape.egg-info/requires.txt +0 -0
  87. {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lanscape
3
- Version: 1.2.10a1
3
+ Version: 1.3.0
4
4
  Summary: A python based local network scanner
5
5
  Author-email: Michael Dennis <michael@dipduo.com>
6
6
  Project-URL: Homepage, https://github.com/mdennis281/py-lanscape
@@ -32,11 +32,12 @@ python -m lanscape
32
32
  ```
33
33
 
34
34
  ## Flags
35
- - `--port <port number>` port of the flask app (default: 5001)
35
+ - `--port <port number>` port of the flask app (default: automagic)
36
+ - `--persistent` dont shutdown server when browser tab is closed (default: false)
36
37
  - `--reloader` essentially flask debug mode- good for local development (default: false)
37
38
  - `--logfile` save log output to lanscape.log
38
39
  - `--loglevel <level>` set the logger's log level (default: INFO)
39
-
40
+ - `--flask-logging` turn on flask logging (default: false)
40
41
 
41
42
  Examples:
42
43
  ```shell
@@ -55,6 +56,14 @@ can sometimes require admin-level permissions to retrieve accurate results.
55
56
  ### Message "WARNING: No libpcap provider available ! pcap won't be used"
56
57
  This is a missing dependency related to the ARP lookup. This is handled in the code, but you would get marginally faster/better results with this installed: [npcap download](https://npcap.com/#download)
57
58
 
59
+ ### The accuracy of the devices found is low
60
+ I use a combination of ARP and Ping to determine if a device is online. This method drops in stability when used in many threads.
61
+ Recommendations:
62
+
63
+ - Drop parallelism value (advanced dropdown)
64
+ - Use python > 3.10 im noticing threadpool improvements after this version
65
+ - Create a bug - I'm curious
66
+
58
67
 
59
68
  ### Something else
60
69
  Feel free to submit a github issue detailing your experience.
@@ -10,11 +10,12 @@ python -m lanscape
10
10
  ```
11
11
 
12
12
  ## Flags
13
- - `--port <port number>` port of the flask app (default: 5001)
13
+ - `--port <port number>` port of the flask app (default: automagic)
14
+ - `--persistent` dont shutdown server when browser tab is closed (default: false)
14
15
  - `--reloader` essentially flask debug mode- good for local development (default: false)
15
16
  - `--logfile` save log output to lanscape.log
16
17
  - `--loglevel <level>` set the logger's log level (default: INFO)
17
-
18
+ - `--flask-logging` turn on flask logging (default: false)
18
19
 
19
20
  Examples:
20
21
  ```shell
@@ -33,6 +34,14 @@ can sometimes require admin-level permissions to retrieve accurate results.
33
34
  ### Message "WARNING: No libpcap provider available ! pcap won't be used"
34
35
  This is a missing dependency related to the ARP lookup. This is handled in the code, but you would get marginally faster/better results with this installed: [npcap download](https://npcap.com/#download)
35
36
 
37
+ ### The accuracy of the devices found is low
38
+ I use a combination of ARP and Ping to determine if a device is online. This method drops in stability when used in many threads.
39
+ Recommendations:
40
+
41
+ - Drop parallelism value (advanced dropdown)
42
+ - Use python > 3.10 im noticing threadpool improvements after this version
43
+ - Create a bug - I'm curious
44
+
36
45
 
37
46
  ### Something else
38
47
  Feel free to submit a github issue detailing your experience.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lanscape"
3
- version = "1.2.10a1"
3
+ version = "1.3.0"
4
4
  authors = [
5
5
  { name="Michael Dennis", email="michael@dipduo.com" },
6
6
  ]
@@ -0,0 +1,29 @@
1
+
2
+
3
+ class SubnetTooLargeError(Exception):
4
+ """Custom exception raised when the subnet size exceeds the allowed limit."""
5
+ def __init__(self, subnet):
6
+ self.subnet = subnet
7
+ super().__init__(f"Subnet {subnet} exceeds the limit of IP addresses.")
8
+
9
+
10
+ class SubnetScanTerminationFailure(Exception):
11
+ def __init__(self,running_threads):
12
+ super().__init__(f'Unable to terminate active threads: {running_threads}')
13
+
14
+ class DeviceError(Exception):
15
+ def __init__(self, e:Exception):
16
+ self.base: Exception = e
17
+ self.method = self._attempt_extract_method()
18
+
19
+ def _attempt_extract_method(self):
20
+ try:
21
+ tb = self.base.__traceback__
22
+ frame = tb.tb_frame
23
+ return frame.f_code.co_name
24
+ except Exception as e:
25
+ print(e)
26
+ return 'unknown'
27
+
28
+ def __str__(self):
29
+ return f'Error(source={self.method}, msg={self.base})'
@@ -3,7 +3,7 @@ from logging.handlers import RotatingFileHandler
3
3
  import click
4
4
 
5
5
 
6
- def configure_logging(loglevel:str, logfile:bool):
6
+ def configure_logging(loglevel:str, logfile:bool, flask_logging:bool=False) -> None:
7
7
  numeric_level = getattr(logging, loglevel.upper(), None)
8
8
  if not isinstance(numeric_level, int):
9
9
  raise ValueError(f'Invalid log level: {loglevel}')
@@ -12,7 +12,7 @@ def configure_logging(loglevel:str, logfile:bool):
12
12
  logging.basicConfig(level=numeric_level, format='[%(name)s] %(levelname)s - %(message)s')
13
13
 
14
14
  # flask spams too much on info
15
- if numeric_level > logging.DEBUG:
15
+ if not flask_logging:
16
16
  disable_flask_logging()
17
17
 
18
18
  if logfile:
@@ -10,58 +10,92 @@ import subprocess
10
10
  from time import sleep
11
11
  from typing import List, Dict
12
12
  from scapy.all import ARP, Ether, srp
13
+ from concurrent.futures import ThreadPoolExecutor, as_completed
13
14
 
14
15
  from .service_scan import scan_service
15
16
  from .mac_lookup import lookup_mac, get_macs
16
17
  from .ip_parser import get_address_count, MAX_IPS_ALLOWED
18
+ from .errors import DeviceError
17
19
 
18
20
  log = logging.getLogger('NetTools')
19
21
 
20
22
 
21
23
  class IPAlive:
22
-
23
- def is_alive(self,ip:str) -> bool:
24
- try:
25
- self.alive = self._arp_lookup(ip)
26
- except:
27
- self.log.debug('failed ARP, falling back to ping')
28
- self.alive = self._ping_lookup(ip)
29
-
30
- return self.alive
31
-
32
- def _arp_lookup(self,ip,timeout=4):
33
- arp_request = ARP(pdst=ip)
34
- broadcast = Ether(dst="ff:ff:ff:ff:ff:ff")
35
- arp_request_broadcast = broadcast / arp_request
24
+ caught_errors: List[DeviceError] = []
36
25
 
37
- # Send the packet and receive the response
38
- answered, _ = srp(arp_request_broadcast, timeout=timeout, verbose=False)
39
-
40
- for sent, received in answered:
41
- if received.psrc == ip:
42
- return True
26
+ def is_alive(self, ip: str) -> bool:
27
+ """
28
+ Run ARP and ping in parallel. As soon as one returns True, we shut
29
+ down the executor (without waiting) and return True. Exceptions
30
+ from either lookup are caught and treated as False.
31
+ """
32
+ executor = ThreadPoolExecutor(max_workers=2)
33
+ futures = [
34
+ executor.submit(self._arp_lookup, ip),
35
+ executor.submit(self._ping_lookup, ip),
36
+ ]
37
+
38
+ for future in as_completed(futures):
39
+ try:
40
+ if future.result():
41
+ # one check succeeded — don't block on the other
42
+ # Cancel remaining futures in a version-compatible way
43
+ for f in futures:
44
+ if not f.done():
45
+ f.cancel()
46
+
47
+ executor.shutdown(wait=False) # Python 3.8 compatible
48
+ return True
49
+ except Exception as e:
50
+ # treat any error as a False response
51
+ log.debug(f'Error while checking {ip}: {e}')
52
+ self.caught_errors.append(DeviceError(e))
53
+
54
+
55
+ # neither check found the host alive
56
+ executor.shutdown()
43
57
  return False
44
58
 
45
- def _ping_lookup(self,host, retries=1, retry_delay=1, ping_count=2, timeout=2):
46
- """
47
- Ping the given host and return True if it's reachable, False otherwise.
48
- """
49
- os = platform.system().lower()
50
- if os == "windows":
51
- ping_command = ['ping', '-n', str(ping_count), '-w', str(timeout*1000)]
52
- else:
53
- ping_command = ['ping', '-c', str(ping_count), '-W', str(timeout)]
54
-
55
- for _ in range(retries):
56
- try:
57
- output = subprocess.check_output(ping_command + [host], stderr=subprocess.STDOUT, universal_newlines=True)
58
- # Check if 'TTL' or 'time' is in the output to determine success
59
- if 'TTL' in output.upper():
60
- return True
61
- except subprocess.CalledProcessError:
62
- pass # Ping failed
59
+ def _arp_lookup(self, ip: str, timeout: int = 3) -> bool:
60
+ arp_request = ARP(pdst=ip)
61
+ broadcast = Ether(dst="ff:ff:ff:ff:ff:ff")
62
+ packet = broadcast / arp_request
63
+
64
+ answered, _ = srp(packet, timeout=timeout, verbose=False)
65
+ return any(resp.psrc == ip for _, resp in answered)
66
+
67
+ def _ping_lookup(
68
+ self, host: str,
69
+ retries: int = 1,
70
+ retry_delay: int = .25,
71
+ ping_count: int = 2,
72
+ timeout: int = 2
73
+ ) -> bool:
74
+ cmd = []
75
+ os_name = platform.system().lower()
76
+ if os_name == "windows":
77
+ # -n count, -w timeout in ms
78
+ cmd = ['ping', '-n', str(ping_count), '-w', str(timeout*1000)]
79
+ else:
80
+ # -c count, -W timeout in s
81
+ cmd = ['ping', '-c', str(ping_count), '-W', str(timeout)]
82
+
83
+ for r in range(retries):
84
+ try:
85
+ output = subprocess.check_output(
86
+ cmd + [host],
87
+ stderr=subprocess.STDOUT,
88
+ universal_newlines=True
89
+ )
90
+ # Windows/Linux both include “TTL” on a successful reply
91
+ if 'TTL' in output.upper():
92
+ return True
93
+ except subprocess.CalledProcessError as e:
94
+ self.caught_errors.append(DeviceError(e))
95
+ pass
96
+ if r < retries - 1:
63
97
  sleep(retry_delay)
64
- return False
98
+ return False
65
99
 
66
100
 
67
101
 
@@ -75,6 +109,7 @@ class Device(IPAlive):
75
109
  self.ports: List[int] = []
76
110
  self.stage: str = 'found'
77
111
  self.services: Dict[str,List[int]] = {}
112
+ self.caught_errors: List[DeviceError] = []
78
113
  self.log = logging.getLogger('Device')
79
114
 
80
115
  def get_metadata(self):
@@ -128,7 +163,8 @@ class Device(IPAlive):
128
163
  try:
129
164
  hostname = socket.gethostbyaddr(self.ip)[0]
130
165
  return hostname
131
- except socket.herror:
166
+ except socket.herror as e:
167
+ self.caught_errors.append(DeviceError(e))
132
168
  return None
133
169
 
134
170
  def _get_manufacturer(self,mac_addr=None):
@@ -9,6 +9,8 @@ class RuntimeArgs:
9
9
  port: int = 5001
10
10
  logfile: bool = False
11
11
  loglevel: str = 'INFO'
12
+ flask_logging: bool = False
13
+ persistent: bool = False
12
14
 
13
15
  def parse_args() -> RuntimeArgs:
14
16
  parser = argparse.ArgumentParser(description='LANscape')
@@ -17,6 +19,8 @@ def parse_args() -> RuntimeArgs:
17
19
  parser.add_argument('--port', type=int, default=5001, help='Port to run the webserver on')
18
20
  parser.add_argument('--logfile', action='store_true', help='Log output to lanscape.log')
19
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')
20
24
 
21
25
  # Parse the arguments
22
26
  args = parser.parse_args()
@@ -10,7 +10,7 @@ from time import sleep
10
10
  from typing import List, Union
11
11
  from tabulate import tabulate
12
12
  from dataclasses import dataclass
13
- from concurrent.futures import ThreadPoolExecutor
13
+ from concurrent.futures import ThreadPoolExecutor, as_completed
14
14
 
15
15
  from .net_tools import Device
16
16
  from .ip_parser import parse_ip_input
@@ -57,6 +57,9 @@ class ScanConfig:
57
57
 
58
58
  def parse_subnet(self) -> List[ipaddress.IPv4Network]:
59
59
  return parse_ip_input(self.subnet)
60
+
61
+ def __str__(self):
62
+ return f'ScanCfg(subnet={self.subnet}, ports={self.port_list}, multiplier={self.t_multiplier})'
60
63
 
61
64
 
62
65
 
@@ -91,7 +94,7 @@ class SubnetScanner:
91
94
  self.running = True
92
95
  with ThreadPoolExecutor(max_workers=self.cfg.t_cnt('isalive')) as executor:
93
96
  futures = {executor.submit(self._get_host_details, str(ip)): str(ip) for ip in self.subnet}
94
- for future in futures:
97
+ for future in as_completed(futures):
95
98
  ip = futures[future]
96
99
  try:
97
100
  future.result()
@@ -183,12 +186,11 @@ class SubnetScanner:
183
186
  Get the MAC address and open ports of the given host.
184
187
  """
185
188
  device = Device(host)
186
- is_alive = self._ping(device)
189
+ device.alive = self._ping(device)
187
190
  self.results.scanned()
188
- if not is_alive:
191
+ if not device.alive:
189
192
  return None
190
193
  self.log.debug(f'[{host}] is alive, getting metadata')
191
-
192
194
  device.get_metadata()
193
195
  self.results.devices.append(device)
194
196
  return True
@@ -290,7 +292,7 @@ class ScannerResults:
290
292
  out['devices'] = [device.dict() for device in sortedDevices]
291
293
 
292
294
  if out_type == str:
293
- return json.dumps(out, indent=2)
295
+ return json.dumps(out,default=str, indent=2)
294
296
  # otherwise return dict
295
297
  return out
296
298
 
@@ -317,44 +319,53 @@ class ScannerResults:
317
319
  class ScanManager:
318
320
  """
319
321
  Maintain active and completed scans in memory for
320
- future reference
322
+ future reference. Singleton implementation.
321
323
  """
324
+ _instance = None
325
+
326
+ def __new__(cls, *args, **kwargs):
327
+ if not cls._instance:
328
+ cls._instance = super(ScanManager, cls).__new__(cls, *args, **kwargs)
329
+ return cls._instance
330
+
322
331
  def __init__(self):
323
- self.scans: List[SubnetScanner] = []
332
+ if not hasattr(self, 'scans'): # Prevent reinitialization
333
+ self.scans: List[SubnetScanner] = []
334
+ self.log = logging.getLogger('ScanManager')
324
335
 
325
336
  def new_scan(self, config: ScanConfig) -> SubnetScanner:
326
337
  scan = SubnetScanner(config)
327
338
  self._start(scan)
339
+ self.log.info(f'Scan started - {config}')
328
340
  self.scans.append(scan)
329
341
  return scan
330
342
 
331
- def get_scan(self,scan_id:str) -> SubnetScanner:
343
+ def get_scan(self, scan_id: str) -> SubnetScanner:
332
344
  """
333
345
  Get scan by scan.uid
334
346
  """
335
347
  for scan in self.scans:
336
348
  if scan.uid == scan_id:
337
349
  return scan
338
-
350
+
339
351
  def terminate_scans(self):
340
352
  """
341
- terminate all active scans
353
+ Terminate all active scans
342
354
  """
343
355
  for scan in self.scans:
344
356
  if scan.running:
345
357
  scan.terminate()
346
358
 
347
- def wait_until_complete(self,scan_id:str) -> SubnetScanner:
359
+ def wait_until_complete(self, scan_id: str) -> SubnetScanner:
348
360
  scan = self.get_scan(scan_id)
349
361
  while scan.running:
350
362
  sleep(.5)
351
363
  return scan
352
364
 
353
- def _start(self,scan:SubnetScanner):
365
+ def _start(self, scan: SubnetScanner):
354
366
  t = threading.Thread(target=scan.start)
355
367
  t.start()
356
368
  return t
357
-
358
369
 
359
370
 
360
371
 
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  import requests
3
3
  import traceback
4
- import pkg_resources
4
+ from importlib.metadata import version, PackageNotFoundError
5
5
  from random import randint
6
6
 
7
7
  from .app_scope import is_local_run
@@ -16,10 +16,14 @@ latest = None # used to 'remember' pypi version each runtime
16
16
  def is_update_available(package=PACKAGE) -> bool:
17
17
  installed = get_installed_version(package)
18
18
  available = lookup_latest_version(package)
19
- if installed == LOCAL_VERSION: return False # local
20
- if 'a' in installed: return False # alpha
21
- if 'b' in installed: return False # beta
22
19
 
20
+ is_update_exempt = (
21
+ 'a' in installed, 'b' in installed, # pre-release
22
+ installed == LOCAL_VERSION
23
+ )
24
+ if any(is_update_exempt): return False
25
+
26
+ log.debug(f'Installed: {installed} | Available: {available}')
23
27
  return installed != available
24
28
 
25
29
  def lookup_latest_version(package=PACKAGE):
@@ -41,8 +45,8 @@ def lookup_latest_version(package=PACKAGE):
41
45
  def get_installed_version(package=PACKAGE):
42
46
  if not is_local_run():
43
47
  try:
44
- return pkg_resources.get_distribution(package).version
45
- except:
48
+ return version(package)
49
+ except PackageNotFoundError:
46
50
  log.debug(traceback.format_exc())
47
51
  log.warning(f'Cannot find {package} installation')
48
52
  return LOCAL_VERSION
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Get the executable path of the system’s default web browser.
4
+
5
+ Supports:
6
+ - Windows (reads from the registry)
7
+ - Linux (uses xdg-mime / xdg-settings + .desktop file parsing)
8
+ """
9
+
10
+ import sys
11
+ import os
12
+ import subprocess
13
+ import webbrowser
14
+ import logging
15
+ import re
16
+ import time
17
+ from typing import Optional
18
+ from ..ui.app import app
19
+
20
+ log = logging.getLogger('WebBrowser')
21
+
22
+
23
+ def open_webapp(url: str) -> bool:
24
+ """
25
+ will try to open the web page as an app
26
+ on failure, will open as a tab in default browser
27
+
28
+ returns:
29
+ """
30
+ start = time.time()
31
+ try:
32
+ exe = get_default_browser_executable()
33
+ if not exe:
34
+ raise RuntimeError('Unable to find browser binary')
35
+ log.debug(f'Opening {url} with {exe}')
36
+
37
+ subprocess.run(f'{exe} --app="{url}"')
38
+
39
+ if time.time() - start < 2:
40
+ log.debug(f'Unable to hook into closure of UI, listening for flask shutdown')
41
+ return False
42
+ return True
43
+
44
+ except Exception as e:
45
+ log.warning('Failed to open webpage as app, falling back to browser tab')
46
+ log.debug(f'As app error: {e}')
47
+ try:
48
+ success = webbrowser.open(url)
49
+ log.debug(f'Opened {url} in browser tab: {success}')
50
+ if not success:
51
+ raise RuntimeError('Unknown error while opening browser tab')
52
+ except Exception as e:
53
+ log.warning(f'Exhausted all options to open browser, you need to open manually')
54
+ log.debug(f'As tab error: {e}')
55
+ log.info(f'LANScape UI is running on {url}')
56
+ return False
57
+
58
+
59
+ def get_default_browser_executable() -> Optional[str]:
60
+ if sys.platform.startswith("win"):
61
+ try:
62
+ import winreg
63
+ # On Windows the HKEY_CLASSES_ROOT\http\shell\open\command key
64
+ # holds the command for opening HTTP URLs.
65
+ with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"http\shell\open\command") as key:
66
+ cmd, _ = winreg.QueryValueEx(key, None)
67
+ except Exception:
68
+ return None
69
+
70
+ # cmd usually looks like: '"C:\\Program Files\\Foo\\foo.exe" %1'
71
+ m = re.match(r'\"?(.+?\.exe)\"?', cmd)
72
+ return m.group(1) if m else None
73
+
74
+ elif sys.platform.startswith("linux"):
75
+ # First, find the .desktop file name
76
+ desktop_file = None
77
+ try:
78
+ # Try xdg-mime
79
+ p = subprocess.run(
80
+ ["xdg-mime", "query", "default", "x-scheme-handler/http"],
81
+ capture_output=True, text=True,
82
+ check=True
83
+ )
84
+ desktop_file = p.stdout.strip()
85
+ except subprocess.CalledProcessError:
86
+ pass
87
+
88
+ if not desktop_file:
89
+ # Fallback to xdg-settings
90
+ try:
91
+ p = subprocess.run(
92
+ ["xdg-settings", "get", "default-web-browser"],
93
+ capture_output=True, text=True,
94
+ check=True
95
+ )
96
+ desktop_file = p.stdout.strip()
97
+ except subprocess.CalledProcessError:
98
+ pass
99
+
100
+ # Final fallback: BROWSER environment variable
101
+ if not desktop_file:
102
+ return os.environ.get("BROWSER")
103
+
104
+ # Look for that .desktop file in standard locations
105
+ search_paths = [
106
+ os.path.expanduser("~/.local/share/applications"),
107
+ "/usr/local/share/applications",
108
+ "/usr/share/applications",
109
+ ]
110
+ for path in search_paths:
111
+ full_path = os.path.join(path, desktop_file)
112
+ if os.path.isfile(full_path):
113
+ with open(full_path, encoding="utf-8", errors="ignore") as f:
114
+ for line in f:
115
+ if line.startswith("Exec="):
116
+ exec_cmd = line[len("Exec="):].strip()
117
+ # strip arguments like “%u”, “--flag”, etc.
118
+ exec_cmd = exec_cmd.split()[0]
119
+ exec_cmd = exec_cmd.split("%")[0]
120
+ return exec_cmd
121
+ return None
122
+
123
+ else:
124
+ raise NotImplementedError(f"Unsupported platform: {sys.platform!r}")
@@ -3,6 +3,7 @@ import json
3
3
  from ..ui.app import app
4
4
  from ..libraries.net_tools import get_network_subnet
5
5
  from ._helpers import right_size_subnet
6
+ import time
6
7
 
7
8
 
8
9
 
@@ -143,7 +144,47 @@ class ApiTestCase(unittest.TestCase):
143
144
  self.assertIsNotNone(data.get('msg'))
144
145
  if count == -1:
145
146
  self.assertFalse(data.get('valid'))
146
-
147
+
148
+ def test_scan_api(self):
149
+ """
150
+ Test the scan API endpoints
151
+ """
152
+ # Create a new scan
153
+ new_scan = {
154
+ 'subnet': right_size_subnet(get_network_subnet()),
155
+ 'port_list': 'small',
156
+ 'parallelism': 1
157
+ }
158
+ response = self.app.post('/api/scan', json=new_scan)
159
+ self.assertEqual(response.status_code, 200)
160
+ scan_info = json.loads(response.data)
161
+ self.assertEqual(scan_info['status'], 'running')
162
+ scan_id = scan_info['scan_id']
163
+ self.assertIsNotNone(scan_id)
164
+
165
+ percent_complete = 0
166
+ while percent_complete < 100:
167
+ # Get scan summary
168
+ response = self.app.get(f'/api/scan/{scan_id}/summary')
169
+ self.assertEqual(response.status_code, 200)
170
+ summary = json.loads(response.data)
171
+ self.assertTrue(summary['running'] or summary['stage'] == 'complete')
172
+ percent_complete = summary['percent_complete']
173
+ self.assertGreaterEqual(percent_complete, 0)
174
+ self.assertLessEqual(percent_complete, 100)
175
+ # Wait for a bit before checking again
176
+ time.sleep(2)
177
+
178
+ self.assertEqual(summary['running'], False)
179
+ self.assertEqual(summary['stage'], 'complete')
180
+ self.assertGreater(summary['runtime'], 0)
181
+
182
+ devices_alive = summary['devices']['alive']
183
+ devices_scanned = summary['devices']['scanned']
184
+ devices_total = summary['devices']['total']
185
+
186
+ self.assertEqual(devices_scanned, devices_total)
187
+ self.assertGreater(devices_alive, 0)
147
188
 
148
189
 
149
190