lanscape 1.2.10a1__py3-none-any.whl → 1.3.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,3 +10,20 @@ class SubnetTooLargeError(Exception):
10
10
  class SubnetScanTerminationFailure(Exception):
11
11
  def __init__(self,running_threads):
12
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,86 @@ 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
+ executor.shutdown(wait=False, cancel_futures=True)
43
+ return True
44
+ except Exception as e:
45
+ # treat any error as a False response
46
+ self.caught_errors.append(DeviceError(e))
47
+ pass
48
+
49
+ # neither check found the host alive
50
+ executor.shutdown()
43
51
  return False
44
52
 
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
53
+ def _arp_lookup(self, ip: str, timeout: int = 3) -> bool:
54
+ arp_request = ARP(pdst=ip)
55
+ broadcast = Ether(dst="ff:ff:ff:ff:ff:ff")
56
+ packet = broadcast / arp_request
57
+
58
+ answered, _ = srp(packet, timeout=timeout, verbose=False)
59
+ return any(resp.psrc == ip for _, resp in answered)
60
+
61
+ def _ping_lookup(
62
+ self, host: str,
63
+ retries: int = 1,
64
+ retry_delay: int = .25,
65
+ ping_count: int = 2,
66
+ timeout: int = 2
67
+ ) -> bool:
68
+ cmd = []
69
+ os_name = platform.system().lower()
70
+ if os_name == "windows":
71
+ # -n count, -w timeout in ms
72
+ cmd = ['ping', '-n', str(ping_count), '-w', str(timeout*1000)]
73
+ else:
74
+ # -c count, -W timeout in s
75
+ cmd = ['ping', '-c', str(ping_count), '-W', str(timeout)]
76
+
77
+ for r in range(retries):
78
+ try:
79
+ output = subprocess.check_output(
80
+ cmd + [host],
81
+ stderr=subprocess.STDOUT,
82
+ universal_newlines=True
83
+ )
84
+ # Windows/Linux both include “TTL” on a successful reply
85
+ if 'TTL' in output.upper():
86
+ return True
87
+ except subprocess.CalledProcessError as e:
88
+ self.caught_errors.append(DeviceError(e))
89
+ pass
90
+ if r < retries - 1:
63
91
  sleep(retry_delay)
64
- return False
92
+ return False
65
93
 
66
94
 
67
95
 
@@ -75,6 +103,7 @@ class Device(IPAlive):
75
103
  self.ports: List[int] = []
76
104
  self.stage: str = 'found'
77
105
  self.services: Dict[str,List[int]] = {}
106
+ self.caught_errors: List[DeviceError] = []
78
107
  self.log = logging.getLogger('Device')
79
108
 
80
109
  def get_metadata(self):
@@ -128,7 +157,8 @@ class Device(IPAlive):
128
157
  try:
129
158
  hostname = socket.gethostbyaddr(self.ip)[0]
130
159
  return hostname
131
- except socket.herror:
160
+ except socket.herror as e:
161
+ self.caught_errors.append(DeviceError(e))
132
162
  return None
133
163
 
134
164
  def _get_manufacturer(self,mac_addr=None):
@@ -9,6 +9,7 @@ class RuntimeArgs:
9
9
  port: int = 5001
10
10
  logfile: bool = False
11
11
  loglevel: str = 'INFO'
12
+ flask_logging: bool = False
12
13
 
13
14
  def parse_args() -> RuntimeArgs:
14
15
  parser = argparse.ArgumentParser(description='LANscape')
@@ -17,6 +18,7 @@ def parse_args() -> RuntimeArgs:
17
18
  parser.add_argument('--port', type=int, default=5001, help='Port to run the webserver on')
18
19
  parser.add_argument('--logfile', action='store_true', help='Log output to lanscape.log')
19
20
  parser.add_argument('--loglevel', default='INFO', help='Set the log level')
21
+ parser.add_argument('--flask-logging', action='store_true', help='Enable flask logging (disables click output)')
20
22
 
21
23
  # Parse the arguments
22
24
  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
 
@@ -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):
@@ -0,0 +1,115 @@
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
+ import traceback
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
+
36
+ #this is blocking until closed
37
+ subprocess.run(f'{exe} --app="{url}"')
38
+ if time.time() - start < 2:
39
+ log.debug(f'Unable to hook into closure of UI, listening for flask shutdown')
40
+ return False
41
+ return True
42
+
43
+ except Exception as e:
44
+ log.warning('Failed to open webpage as app, falling back to browser tab')
45
+ log.debug(e)
46
+ webbrowser.open(url, new=2)
47
+ return False
48
+
49
+
50
+ def get_default_browser_executable() -> str | None:
51
+ if sys.platform.startswith("win"):
52
+ try:
53
+ import winreg
54
+ # On Windows the HKEY_CLASSES_ROOT\http\shell\open\command key
55
+ # holds the command for opening HTTP URLs.
56
+ with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"http\shell\open\command") as key:
57
+ cmd, _ = winreg.QueryValueEx(key, None)
58
+ except Exception:
59
+ return None
60
+
61
+ # cmd usually looks like: '"C:\\Program Files\\Foo\\foo.exe" %1'
62
+ m = re.match(r'\"?(.+?\.exe)\"?', cmd)
63
+ return m.group(1) if m else None
64
+
65
+ elif sys.platform.startswith("linux"):
66
+ # First, find the .desktop file name
67
+ desktop_file = None
68
+ try:
69
+ # Try xdg-mime
70
+ p = subprocess.run(
71
+ ["xdg-mime", "query", "default", "x-scheme-handler/http"],
72
+ capture_output=True, text=True,
73
+ check=True
74
+ )
75
+ desktop_file = p.stdout.strip()
76
+ except subprocess.CalledProcessError:
77
+ pass
78
+
79
+ if not desktop_file:
80
+ # Fallback to xdg-settings
81
+ try:
82
+ p = subprocess.run(
83
+ ["xdg-settings", "get", "default-web-browser"],
84
+ capture_output=True, text=True,
85
+ check=True
86
+ )
87
+ desktop_file = p.stdout.strip()
88
+ except subprocess.CalledProcessError:
89
+ pass
90
+
91
+ # Final fallback: BROWSER environment variable
92
+ if not desktop_file:
93
+ return os.environ.get("BROWSER")
94
+
95
+ # Look for that .desktop file in standard locations
96
+ search_paths = [
97
+ os.path.expanduser("~/.local/share/applications"),
98
+ "/usr/local/share/applications",
99
+ "/usr/share/applications",
100
+ ]
101
+ for path in search_paths:
102
+ full_path = os.path.join(path, desktop_file)
103
+ if os.path.isfile(full_path):
104
+ with open(full_path, encoding="utf-8", errors="ignore") as f:
105
+ for line in f:
106
+ if line.startswith("Exec="):
107
+ exec_cmd = line[len("Exec="):].strip()
108
+ # strip arguments like “%u”, “--flag”, etc.
109
+ exec_cmd = exec_cmd.split()[0]
110
+ exec_cmd = exec_cmd.split("%")[0]
111
+ return exec_cmd
112
+ return None
113
+
114
+ else:
115
+ 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
 
@@ -89,6 +90,7 @@ class ApiTestCase(unittest.TestCase):
89
90
  ]
90
91
  for uri in uris:
91
92
  response = self.app.get(uri)
93
+ print(uri, response.status_code)
92
94
  self.assertEqual(response.status_code,200)
93
95
 
94
96
 
@@ -143,7 +145,47 @@ class ApiTestCase(unittest.TestCase):
143
145
  self.assertIsNotNone(data.get('msg'))
144
146
  if count == -1:
145
147
  self.assertFalse(data.get('valid'))
146
-
148
+
149
+ def test_scan_api(self):
150
+ """
151
+ Test the scan API endpoints
152
+ """
153
+ # Create a new scan
154
+ new_scan = {
155
+ 'subnet': right_size_subnet(get_network_subnet()),
156
+ 'port_list': 'small',
157
+ 'parallelism': 1
158
+ }
159
+ response = self.app.post('/api/scan', json=new_scan)
160
+ self.assertEqual(response.status_code, 200)
161
+ scan_info = json.loads(response.data)
162
+ self.assertEqual(scan_info['status'], 'running')
163
+ scan_id = scan_info['scan_id']
164
+ self.assertIsNotNone(scan_id)
165
+
166
+ percent_complete = 0
167
+ while percent_complete < 100:
168
+ # Get scan summary
169
+ response = self.app.get(f'/api/scan/{scan_id}/summary')
170
+ self.assertEqual(response.status_code, 200)
171
+ summary = json.loads(response.data)
172
+ self.assertTrue(summary['running'] or summary['stage'] == 'complete')
173
+ percent_complete = summary['percent_complete']
174
+ self.assertGreaterEqual(percent_complete, 0)
175
+ self.assertLessEqual(percent_complete, 100)
176
+ # Wait for a bit before checking again
177
+ time.sleep(2)
178
+
179
+ self.assertEqual(summary['running'], False)
180
+ self.assertEqual(summary['stage'], 'complete')
181
+ self.assertGreater(summary['runtime'], 0)
182
+
183
+ devices_alive = summary['devices']['alive']
184
+ devices_scanned = summary['devices']['scanned']
185
+ devices_total = summary['devices']['total']
186
+
187
+ self.assertEqual(devices_scanned, devices_total)
188
+ self.assertGreater(devices_alive, 0)
147
189
 
148
190
 
149
191
 
lanscape/ui/app.py CHANGED
@@ -13,7 +13,7 @@ from ..libraries.app_scope import is_local_run
13
13
  app = Flask(
14
14
  __name__
15
15
  )
16
- log = logging.getLogger('core')
16
+ log = logging.getLogger('flask')
17
17
 
18
18
  ## Import and register BPs
19
19
  ################################
@@ -89,12 +89,12 @@ def internal_error(e):
89
89
  ## Webserver creation functions
90
90
  ################################
91
91
 
92
- def start_webserver_dameon(args: RuntimeArgs) -> multiprocessing.Process:
92
+ def start_webserver_dameon(args: RuntimeArgs) -> threading.Thread:
93
93
  proc = threading.Thread(target=start_webserver, args=(args,))
94
94
  proc.daemon = True # Kill thread when main thread exits
95
95
  proc.start()
96
96
  log.info('Flask server initializing as dameon')
97
- sleep(2)
97
+ return proc
98
98
 
99
99
  def start_webserver(args: RuntimeArgs) -> int:
100
100
  run_args = {
@@ -103,10 +103,7 @@ def start_webserver(args: RuntimeArgs) -> int:
103
103
  'debug':args.reloader,
104
104
  'use_reloader':args.reloader
105
105
  }
106
-
107
106
  app.run(**run_args)
108
107
 
109
- if __name__ == "__main__":
110
- start_webserver(True)
111
108
 
112
109
 
@@ -3,6 +3,7 @@ from ....libraries.subnet_scan import ScanConfig
3
3
  from .. import scan_manager
4
4
 
5
5
  from flask import request, jsonify
6
+ import json
6
7
  import traceback
7
8
 
8
9
  # Subnet Scanner API
@@ -30,7 +31,8 @@ def scan_subnet_async():
30
31
  @api_bp.route('/api/scan/<scan_id>', methods=['GET'])
31
32
  def get_scan(scan_id):
32
33
  scan = scan_manager.get_scan(scan_id)
33
- return jsonify(scan.results.export())
34
+ # cast to str and back to handle custom JSON serialization
35
+ return jsonify(json.loads(scan.results.export(str)))
34
36
 
35
37
  @api_bp.route('/api/scan/<scan_id>/summary',methods=['GET'])
36
38
  def get_scan_summary(scan_id):
@@ -16,47 +16,54 @@ def index():
16
16
  subnet = smart_select_primary_subnet(subnets)
17
17
 
18
18
  port_list = 'medium'
19
- parallelism = 0.7
19
+ parallelism = 1
20
20
  if scan_id := request.args.get('scan_id'):
21
- if scanner := scan_manager.get_scan(scan_id):
22
- scan = scanner.results.export()
23
- subnet = scan['subnet']
24
- port_list = scan['port_list']
25
- parallelism = scan['cfg']['t_multiplier']
21
+ if scan := scan_manager.get_scan(scan_id):
22
+ subnet = scan.cfg.subnet
23
+ port_list = scan.cfg.port_list
24
+ parallelism = scan.cfg.t_multiplier
25
+
26
26
  else:
27
27
  log.debug(f'Redirecting, scan {scan_id} doesnt exist in memory')
28
28
  return redirect('/')
29
29
  return render_template(
30
- 'main.html',
31
- subnet=subnet,
32
- port_list=port_list,
33
- parallelism=parallelism,
34
- alternate_subnets=subnets
35
- )
30
+ 'main.html',
31
+ subnet=subnet,
32
+ port_list=port_list,
33
+ parallelism=parallelism,
34
+ alternate_subnets=subnets
35
+ )
36
+
36
37
 
37
38
  @web_bp.route('/scan/<scan_id>', methods=['GET'])
38
39
  @web_bp.route('/scan/<scan_id>/<section>', methods=['GET'])
39
40
  def render_scan(scan_id, section='all'):
40
- scanner = scan_manager.get_scan(scan_id)
41
- data = scanner.results.export()
42
- filter = request.args.get('filter')
43
- return render_template('scan.html', data=data, section=section, filter=filter)
41
+ if scanner := scan_manager.get_scan(scan_id):
42
+ data = scanner.results.export()
43
+ filter = request.args.get('filter')
44
+ return render_template('scan.html', data=data, section=section, filter=filter)
45
+ log.debug(f'Redirecting, scan {scan_id} doesnt exist in memory')
46
+ return redirect('/')
44
47
 
45
48
  @web_bp.route('/errors/<scan_id>')
46
49
  def view_errors(scan_id):
47
- scanner = scan_manager.get_scan(scan_id)
48
- data = scanner.results.export()
49
- return render_template('scan/scan-error.html',data=data)
50
+ if scanner := scan_manager.get_scan(scan_id):
51
+ data = scanner.results.export()
52
+ return render_template('scan/scan-error.html',data=data)
53
+ log.debug(f'Redirecting, scan {scan_id} doesnt exist in memory')
54
+ return redirect('/')
50
55
 
51
56
  @web_bp.route('/export/<scan_id>')
52
57
  def export_scan(scan_id):
53
- scanner = scan_manager.get_scan(scan_id)
54
- export_json = scanner.results.export(str)
55
- return render_template(
56
- 'scan/export.html',
57
- scan=scanner,
58
- export_json=export_json
59
- )
58
+ if scanner := scan_manager.get_scan(scan_id):
59
+ export_json = scanner.results.export(str)
60
+ return render_template(
61
+ 'scan/export.html',
62
+ scan=scanner,
63
+ export_json=export_json
64
+ )
65
+ log.debug(f'Redirecting, scan {scan_id} doesnt exist in memory')
66
+ return redirect('/')
60
67
 
61
68
  @web_bp.route('/shutdown-ui')
62
69
  def shutdown_ui():
lanscape/ui/main.py CHANGED
@@ -1,18 +1,18 @@
1
1
 
2
2
  import threading
3
- import webbrowser
4
3
  import time
5
4
  import logging
6
5
  import traceback
7
6
  import os
8
7
  from ..libraries.logger import configure_logging
9
8
  from ..libraries.runtime_args import parse_args, RuntimeArgs
9
+ from ..libraries.web_browser import open_webapp
10
10
  # do this so any logs generated on import are displayed
11
11
  args = parse_args()
12
- configure_logging(args.loglevel, args.logfile)
12
+ configure_logging(args.loglevel, args.logfile, args.flask_logging)
13
13
 
14
14
  from ..libraries.version_manager import get_installed_version, is_update_available
15
- from .app import start_webserver
15
+ from .app import start_webserver_dameon, start_webserver
16
16
  import socket
17
17
 
18
18
 
@@ -22,9 +22,19 @@ log = logging.getLogger('core')
22
22
  IS_FLASK_RELOAD = os.environ.get("WERKZEUG_RUN_MAIN")
23
23
 
24
24
 
25
+ def main():
26
+ try:
27
+ _main()
28
+ except KeyboardInterrupt:
29
+ log.info('Keyboard interrupt received, terminating...')
30
+ terminate()
31
+ except Exception as e:
32
+ log.critical(f'Unexpected error: {e}')
33
+ log.debug(traceback.format_exc())
34
+ terminate()
25
35
 
26
36
 
27
- def main():
37
+ def _main():
28
38
  if not IS_FLASK_RELOAD:
29
39
  log.info(f'LANscape v{get_installed_version()}')
30
40
  try_check_update()
@@ -36,12 +46,11 @@ def main():
36
46
 
37
47
 
38
48
  try:
39
-
40
- no_gui(args)
41
-
49
+ start_webserver_ui(args)
42
50
  log.info('Exiting...')
43
- except Exception:
51
+ except Exception as e:
44
52
  # showing error in debug only because this is handled gracefully
53
+ log.critical(f'Failed to start app. Error: {e}')
45
54
  log.debug('Failed to start. Traceback below')
46
55
  log.debug(traceback.format_exc())
47
56
 
@@ -57,31 +66,52 @@ def try_check_update():
57
66
  log.warning('Unable to check for updates.')
58
67
 
59
68
 
60
- def open_browser(url: str,wait=2):
69
+ def open_browser(url: str, wait=2) -> bool:
61
70
  """
62
71
  Open a browser window to the specified
63
72
  url after waiting for the server to start
64
73
  """
65
- def do_open():
66
- try:
67
- time.sleep(wait)
68
- webbrowser.open(url, new=2)
69
- except:
70
- log.debug(traceback.format_exc())
71
- log.info(f'Unable to open web browser, server running on {url}')
72
-
73
- threading.Thread(target=do_open).start()
74
-
75
- def no_gui(args: RuntimeArgs):
76
- # determine if it was reloaded by flask debug reloader
77
- # if it was, dont open the browser again
78
- if not IS_FLASK_RELOAD:
79
- open_browser(f'http://127.0.0.1:{args.port}')
80
- log.info(f'Flask started: http://127.0.0.1:{args.port}')
74
+ try:
75
+ time.sleep(wait)
76
+ log.info(f'Starting UI - http://127.0.0.1:{args.port}')
77
+ return open_webapp(url)
78
+
79
+ except:
80
+ log.debug(traceback.format_exc())
81
+ log.info(f'Unable to open web browser, server running on {url}')
82
+ return False
83
+
84
+
85
+
86
+ def start_webserver_ui(args: RuntimeArgs):
87
+ uri = f'http://127.0.0.1:{args.port}'
88
+
89
+ # running reloader requires flask to run in main thread
90
+ # this decouples UI from main process
91
+ if args.reloader:
92
+ # determine if it was reloaded by flask debug reloader
93
+ # if it was, dont open the browser again
94
+ log.info('Opening UI as daemon')
95
+ if not IS_FLASK_RELOAD:
96
+ threading.Thread(
97
+ target=open_browser,
98
+ args=(uri,),
99
+ daemon=True
100
+ ).start()
101
+ start_webserver(args)
102
+ else:
103
+ flask_thread = start_webserver_dameon(args)
104
+ app_closed = open_browser(uri)
105
+
106
+ # depending on env, open_browser may or
107
+ # may not be coupled with the closure of UI
108
+ # (if in browser tab, it's uncoupled)
109
+ if not app_closed:
110
+ # not doing a direct join so i can still
111
+ # terminate the app with ctrl+c
112
+ while flask_thread.is_alive():
113
+ time.sleep(1)
81
114
 
82
- start_webserver(
83
- args
84
- )
85
115
 
86
116
  def get_valid_port(port: int):
87
117
  """
@@ -93,6 +123,12 @@ def get_valid_port(port: int):
93
123
  return port
94
124
  port += 1
95
125
 
126
+ def terminate():
127
+ import requests
128
+ log.info('Attempting flask shutdown')
129
+ requests.get(f'http://127.0.0.1:{args.port}/shutdown')
130
+
131
+
132
+
96
133
  if __name__ == "__main__":
97
- main()
98
-
134
+ main()
@@ -670,7 +670,9 @@ html {
670
670
 
671
671
  #scan-devices-alive,
672
672
  #scan-devices-scanned,
673
- #scan-devices-total {
673
+ #scan-devices-total,
674
+ #scan-run-time,
675
+ #scan-remain-time {
674
676
  margin: 3px;
675
677
  }
676
678
 
Binary file
@@ -187,10 +187,44 @@ function pollScanSummary(id) {
187
187
  }
188
188
 
189
189
  function updateOverviewUI(summary) {
190
- $('#scan-devices-alive').text(summary.devices.alive);
191
- $('#scan-devices-scanned').text(summary.devices.scanned);
192
- $('#scan-devices-total').text(summary.devices.total);
193
- $('#scan-run-time').text(summary.runtime);
190
+ // helper to turn a number of seconds into "MM:SS"
191
+ function formatMMSS(totalSeconds) {
192
+ const secs = Math.floor(totalSeconds);
193
+ const m = Math.floor(secs / 60);
194
+ const s = secs % 60;
195
+ // pad minutes and seconds to 2 digits
196
+ const mm = String(m).padStart(2, '0');
197
+ const ss = String(s).padStart(2, '0');
198
+ return `${mm}:${ss}`;
199
+ }
200
+
201
+ const alive = summary.devices.alive;
202
+ const scanned = summary.devices.scanned;
203
+ const total = summary.devices.total;
204
+
205
+ // ensure we have a number of elapsed seconds
206
+ const runtimeSec = parseFloat(summary.runtime) || 0;
207
+ const pctComplete = Number(summary.percent_complete) || 0;
208
+
209
+ // compute remaining seconds correctly
210
+ const remainingSec = pctComplete > 0
211
+ ? (runtimeSec * (100 - pctComplete)) / pctComplete
212
+ : 0;
213
+
214
+ // update everything…
215
+ $('#scan-devices-alive').text(alive);
216
+ $('#scan-devices-scanned').text(scanned);
217
+ $('#scan-devices-total').text(total);
218
+
219
+ // …but format runtime and remaining as MM:SS
220
+ $('#scan-run-time').text(formatMMSS(runtimeSec));
221
+ if (pctComplete < 10) {
222
+ $('#scan-remain-time').text('??:??');
223
+ } else {
224
+ $('#scan-remain-time').text(formatMMSS(remainingSec));
225
+ }
226
+
227
+
194
228
  $('#scan-stage').text(summary.stage);
195
229
  }
196
230
 
@@ -11,9 +11,10 @@
11
11
  </div>
12
12
  <div class="col-4">
13
13
  <div class="card text-white overview-card" id="runtime-card">
14
- <div class="card-header text-center">Runtime</div>
14
+ <div class="card-header text-center">Runtime / Remain</div>
15
15
  <div class="card-body text-center">
16
- <span id="scan-run-time"></span>s
16
+ <span id="scan-run-time"></span> /
17
+ <span id="scan-remain-time"></span>
17
18
  </div>
18
19
  </div>
19
20
  </div>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lanscape
3
- Version: 1.2.10a1
3
+ Version: 1.3.0a1
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
@@ -36,7 +36,7 @@ python -m lanscape
36
36
  - `--reloader` essentially flask debug mode- good for local development (default: false)
37
37
  - `--logfile` save log output to lanscape.log
38
38
  - `--loglevel <level>` set the logger's log level (default: INFO)
39
-
39
+ - `--flask-logging` turn on flask logging (default: false)
40
40
 
41
41
  Examples:
42
42
  ```shell
@@ -2,16 +2,17 @@ lanscape/__init__.py,sha256=_8FoHQKR0s9B_stjs5e5CnytwbSK1JgNvE2kZBDrWbw,180
2
2
  lanscape/__main__.py,sha256=Im2Qc9AIScEvjRik_X4x63n0Rie67-myQbuIEU7I-Ac,129
3
3
  lanscape/libraries/app_scope.py,sha256=oPRrYIXOn914gF1DTVCDcy1d97hjReFlAxJNBjseBIo,2447
4
4
  lanscape/libraries/decorators.py,sha256=Birxor-ae-CQRlBVOyB5DggHnuMQR98IYZ1FVHdGZzE,2448
5
- lanscape/libraries/errors.py,sha256=zvfqvJbIUsUWsEmw6XmUWXZsktbVachGIkYOdo1nR8s,447
5
+ lanscape/libraries/errors.py,sha256=DaercNEZD_tUuXF7KsNk3SD6AqAwT-S7fvzpEybVn08,964
6
6
  lanscape/libraries/ip_parser.py,sha256=ElXz3LU5CUYWqKOHEyrj5Y4Iv6OBtoSlbCcxhCsibfQ,2226
7
- lanscape/libraries/logger.py,sha256=hGbnj1cTGZT5elwdXUBVyLg_E2Pnocs2IfWumUkHj7M,1385
7
+ lanscape/libraries/logger.py,sha256=doD8KKb4TNWDwVXc1VR7NK4UdharrAoRHl8vZnSAupI,1407
8
8
  lanscape/libraries/mac_lookup.py,sha256=-dRV0ygtjjh3JkgL3GTi_5-w7pcZ1oj4XVH4chjsmRs,2121
9
- lanscape/libraries/net_tools.py,sha256=dUuLsxS_ypi9joZjlkn0dfzxwtT__85gOUkBCXvKBXM,10436
9
+ lanscape/libraries/net_tools.py,sha256=boiccYtpv3v37l239b9jQvYUBgz_ynm0W149ceVCSBU,11473
10
10
  lanscape/libraries/port_manager.py,sha256=fNext3FNfGnGYRZK9RhTEwQ2K0e0YmmMlhK4zVAvoCw,1977
11
- lanscape/libraries/runtime_args.py,sha256=JJTzWgQ-0aRh7Ce5efIwBmHZv1LfWjFagtNUNlLN7Uo,1660
11
+ lanscape/libraries/runtime_args.py,sha256=zL8QB_Y69OBGjScytuuyHqdy2XimwpypMSPM3_etz7g,1811
12
12
  lanscape/libraries/service_scan.py,sha256=jLU84ZoJnqSQbE30Zly2lm2zHrCGutNXjla1sEvp1hE,1949
13
- lanscape/libraries/subnet_scan.py,sha256=qgyiOcnZt56gRS-Dn52Y4YL3KuqNEQ-BadrxgX3_AII,12542
14
- lanscape/libraries/version_manager.py,sha256=wdB5DPW8IjHJfgvT4mUKz7ndEV922WgVEqbwd6jfun4,1599
13
+ lanscape/libraries/subnet_scan.py,sha256=0LW_xdoL-PRp59rJr6r6pSL3LiXEO_SJnjdrgEF_pO8,13120
14
+ lanscape/libraries/version_manager.py,sha256=Mh8VptGmv03d0tCpS-DFVgsxD5qWMA8iIG1KVcWOp4A,1690
15
+ lanscape/libraries/web_browser.py,sha256=xL2nGnqDTYJ1joMg2mT4uKX0Yq-4mI9svMjjrWyzkRg,3879
15
16
  lanscape/resources/mac_addresses/convert_csv.py,sha256=w3Heed5z2mHYDEZNBep3_hNg4dbrp_N6J54MGxnrq4s,721
16
17
  lanscape/resources/mac_addresses/mac_db.json,sha256=ygtFSwNwJzDlg6hmAujdgCyzUjxt9Di75J8SO4xYIs8,2187804
17
18
  lanscape/resources/ports/convert_csv.py,sha256=mWe8zucWVfnlNEx_ZzH5Vc3tJJbdi-Ih4nm2yKNrRN0,720
@@ -22,30 +23,30 @@ lanscape/resources/ports/small.json,sha256=Mj3zGVG1F2eqZx2YkrLpTL8STeLcqB8_65IR6
22
23
  lanscape/resources/services/definitions.jsonc,sha256=71w9Q7r4RoBYiIMkzzO2KdEJXaSIchNccYQueqAhD4E,8842
23
24
  lanscape/tests/__init__.py,sha256=xYPeceOF-ppTS0wnq7CkVYQMwupmeSHxbYLbGj_imZ0,113
24
25
  lanscape/tests/_helpers.py,sha256=wXJfUwzL3Fq4XBsC3dValCbXsf0U8FisuM_yo1de4QQ,371
25
- lanscape/tests/test_api.py,sha256=fIVIA6O9ssPRjofTHLS6z7XPNTkvv2rl2jDaxVCjFGU,5669
26
+ lanscape/tests/test_api.py,sha256=881eHwSHT5GnOB61zbt3BRzCZnSwN2lndwCM5kIHLgo,7359
26
27
  lanscape/tests/test_env.py,sha256=ivFhCcemJ9vbe0_KtUkbqDY4r9nsDB8rVLUVjV-sNj8,673
27
28
  lanscape/tests/test_library.py,sha256=OPcTsUoR5IureSNDbePxid2BG98mfNNIJmCIY0BVz3w,1553
28
- lanscape/ui/app.py,sha256=hGmzoyH4YW2WmQsqZ6v-9r5DYzWi2KYP6_toZSxQqFg,3186
29
- lanscape/ui/main.py,sha256=HMQythH0fijX1Mt9fGQBcqmY37dQQ19ROC99JbCNAvs,2701
29
+ lanscape/ui/app.py,sha256=Efa9gJnAcA_a3taCne9EHU4YxTCoRDd1M6UGa3rb2Qw,3126
30
+ lanscape/ui/main.py,sha256=zDs7epDeXRoxZAHt9aLXB3kypXdZa1pSeqCIitwN7Ig,3998
30
31
  lanscape/ui/blueprints/__init__.py,sha256=agvgPOSVbrxddaw6EY64ZZr1CQi1Qzwcs1t0lZMv5oY,206
31
32
  lanscape/ui/blueprints/api/__init__.py,sha256=t0QOq3vHFWmlZm_3YFPQbQzCn1a_a5cmRchtIxwy4eY,103
32
33
  lanscape/ui/blueprints/api/port.py,sha256=2UA38umzXE8pMitx1E-_wJHyL1dYYbtM6Kg5zVtfj6A,1019
33
- lanscape/ui/blueprints/api/scan.py,sha256=ch4AcZ2nJLAXj0skHo3dMibpAIg4A_BSa2pZ0U782Wo,2137
34
+ lanscape/ui/blueprints/api/scan.py,sha256=vNHH5XIVM8hWsHpkBX2okNFXWSFWz0E1RQuom8D-9TI,2229
34
35
  lanscape/ui/blueprints/api/tools.py,sha256=CD0NDSX8kN6_lpl0jEw-ULLsDx7pKODCMFQiaK4GCzM,1153
35
36
  lanscape/ui/blueprints/web/__init__.py,sha256=-WRjENG8D99NfaiSDk9uAa8OX6XJq9Zmq1ck29ARL-w,92
36
- lanscape/ui/blueprints/web/routes.py,sha256=Cd_XpDCSt02_K1hoJoegacA_eMhmqZdz0R0QMsZ2K0s,2174
37
+ lanscape/ui/blueprints/web/routes.py,sha256=hl89T5_oRbTlA9Cde_xh9zAQDGRRH-Dpwm74B1_NM1Y,2511
37
38
  lanscape/ui/static/lanscape.webmanifest,sha256=0aauJk_Bybd0B2iwzJfvPcs7AX43kVHs0dtpV6_jSWk,459
38
- lanscape/ui/static/css/style.css,sha256=LbQ4O98uttwY2Msxv9XnniOTzZApGsqkFg8PCoe1ZcQ,15801
39
- lanscape/ui/static/img/ico/android-chrome-192x192.png,sha256=DxM2E9GjpKX-hSaSmAoW0GxLJ2fdXKJ-WOgoxYlDybw,24130
40
- lanscape/ui/static/img/ico/android-chrome-512x512.png,sha256=UoL_8UdhGU1q2_XkJ2T7QojgcNnz7FrBChlVRaMOWIs,73788
41
- lanscape/ui/static/img/ico/apple-touch-icon.png,sha256=Yhzhva3J_wjnC3zk2e7GXhEVNLozGVE_IjQkg4R5Ip4,21664
42
- lanscape/ui/static/img/ico/favicon-16x16.png,sha256=IRCDDp-lTndprI1-hMY8Cyg1OvJhOkzX73i5i246p50,808
43
- lanscape/ui/static/img/ico/favicon-32x32.png,sha256=pbG3WlLqIHHRGy-0JbsJ7ybBL6enl8NV79b_SybxiGg,2137
44
- lanscape/ui/static/img/ico/favicon.ico,sha256=2fKhUCOFv7vH_WwGPhemSHoJeIXQl0-iq8cJ0QBewng,15406
39
+ lanscape/ui/static/css/style.css,sha256=ZoROzQmBIZlMdz0i9YHkx0Eomo87nQcDLJnGiiC0S3Y,15838
40
+ lanscape/ui/static/img/ico/android-chrome-192x192.png,sha256=JmFT6KBCCuoyxMV-mLNtF9_QJbVBvfWPUizKN700fi8,18255
41
+ lanscape/ui/static/img/ico/android-chrome-512x512.png,sha256=88Jjx_1-4XAnZYz64KP6FdTl_kYkNG2_kQIKteQwSh4,138055
42
+ lanscape/ui/static/img/ico/apple-touch-icon.png,sha256=tEJlLwBZtF4v-NC90YCfRJQ2prTsF4i3VQLK_hnv2Mw,16523
43
+ lanscape/ui/static/img/ico/favicon-16x16.png,sha256=HpQOZk3rziZjT1xQxKuy5WourXsfrdwuzQY1hChzBJQ,573
44
+ lanscape/ui/static/img/ico/favicon-32x32.png,sha256=UpgiDPIHckK19udHtACiaI3ZPbmImUUcN1GcrjpEg9s,1302
45
+ lanscape/ui/static/img/ico/favicon.ico,sha256=rs5vq0MPJ1LzzioOzOz5aQLVfrtS2nLRc920dOeReTw,15406
45
46
  lanscape/ui/static/img/ico/site.webmanifest,sha256=ep4Hzh9zhmiZF2At3Fp1dQrYQuYF_3ZPZxc1KcGBvwQ,263
46
47
  lanscape/ui/static/js/core.js,sha256=y-f8iQPIetllUY0lSCwnGbPCk5fTJbbU6Pxm3rw1EBU,1111
47
48
  lanscape/ui/static/js/layout-sizing.js,sha256=23UuKdEmRChg6fyqj3DRvcsNfMoa6MRt6dkaT0k7_UY,841
48
- lanscape/ui/static/js/main.js,sha256=qKy_dukRbn5t9qBiL1TNS3wyiH-F2sE6WtSSRjOprKw,6583
49
+ lanscape/ui/static/js/main.js,sha256=s0ipGqmuuFHnH9KKoUVaDRJ10_YqYoJ-9r_YnbsH8Mw,7676
49
50
  lanscape/ui/static/js/quietReload.js,sha256=_mHzpUsGL4Lm1hNsr3VYSOGVcgGA2y1-eZHacssTXGs,724
50
51
  lanscape/ui/static/js/shutdown-server.js,sha256=WkO7_SNSHM_6kReUoCoExIdCf7Sl7IPiSiNxpbI-r0s,469
51
52
  lanscape/ui/static/js/subnet-info.js,sha256=aytt0LkBx4FVq36TxiMEw3aM7XQLHg_ng1U2WDwZVF4,577
@@ -61,10 +62,10 @@ lanscape/ui/templates/core/scripts.html,sha256=TRW74VUDasOTFYkaDhKKFnEwHyNx-_rmz
61
62
  lanscape/ui/templates/scan/export.html,sha256=Qi0m2xJPbC5I2rxzekXjvQ6q9gm2Lr4VJW6riLhIaU0,776
62
63
  lanscape/ui/templates/scan/ip-table-row.html,sha256=ptY24rxJRaA4PEEQRDncaq6Q0ql5RJ87Kn0zKRCzOHw,4842
63
64
  lanscape/ui/templates/scan/ip-table.html,sha256=ds__UP9JiTKf5IxCmTMzw--eN_yg1Pvn3Nj1KvQxeZg,940
64
- lanscape/ui/templates/scan/overview.html,sha256=Q0gmkVI-xNLZ-zWA9qm4H14cv_eF_bQs1KYPyAaoHLY,1141
65
+ lanscape/ui/templates/scan/overview.html,sha256=FsX-jSFhGKwCxZGKE8AMKk328UuawN6O9RNTzYvIOts,1205
65
66
  lanscape/ui/templates/scan/scan-error.html,sha256=Q4eZM5ThrxnFaWOSTUpK8hA2ksHwhxOBTaVUCLALhyA,1032
66
- lanscape-1.2.10a1.dist-info/licenses/LICENSE,sha256=cCO-NbS01Ilwc6djHjZ7LIgPFRkRmWdr0fH2ysXKioA,1090
67
- lanscape-1.2.10a1.dist-info/METADATA,sha256=1bUCVjTGALGEVANjuWWfoMtfKFYcZ1XU-47bmYtdCx0,2063
68
- lanscape-1.2.10a1.dist-info/WHEEL,sha256=0CuiUZ_p9E4cD6NyLD6UG80LBXYyiSYZOKDm5lp32xk,91
69
- lanscape-1.2.10a1.dist-info/top_level.txt,sha256=E9D4sjPz_6H7c85Ycy_pOS2xuv1Wm-ilKhxEprln2ps,9
70
- lanscape-1.2.10a1.dist-info/RECORD,,
67
+ lanscape-1.3.0a1.dist-info/licenses/LICENSE,sha256=cCO-NbS01Ilwc6djHjZ7LIgPFRkRmWdr0fH2ysXKioA,1090
68
+ lanscape-1.3.0a1.dist-info/METADATA,sha256=Vig-rxR7BRUlyBcpIJDeA4mPsS9HIIQHPoJIVOof5IA,2120
69
+ lanscape-1.3.0a1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
70
+ lanscape-1.3.0a1.dist-info/top_level.txt,sha256=E9D4sjPz_6H7c85Ycy_pOS2xuv1Wm-ilKhxEprln2ps,9
71
+ lanscape-1.3.0a1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.3.1)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5