lanscape 1.3.0a6__py3-none-any.whl → 1.3.1__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.

@@ -9,7 +9,9 @@ import traceback
9
9
  import subprocess
10
10
  from time import sleep
11
11
  from typing import List, Dict
12
- from scapy.all import ARP, Ether, srp
12
+ from scapy.sendrecv import srp
13
+ from scapy.layers.l2 import ARP, Ether
14
+ from scapy.error import Scapy_Exception
13
15
  from concurrent.futures import ThreadPoolExecutor, as_completed
14
16
 
15
17
  from .service_scan import scan_service
@@ -38,13 +40,19 @@ class IPAlive:
38
40
  for future in as_completed(futures):
39
41
  try:
40
42
  if future.result():
41
- # one check succeeded — dont block on the other
42
- executor.shutdown(wait=False, cancel_futures=True)
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
43
50
  return True
44
51
  except Exception as e:
45
52
  # treat any error as a False response
53
+ log.debug(f'Error while checking {ip}: {e}')
46
54
  self.caught_errors.append(DeviceError(e))
47
- pass
55
+
48
56
 
49
57
  # neither check found the host alive
50
58
  executor.shutdown()
@@ -60,7 +68,7 @@ class IPAlive:
60
68
 
61
69
  def _ping_lookup(
62
70
  self, host: str,
63
- retries: int = 1,
71
+ retries: int = 2,
64
72
  retry_delay: int = .25,
65
73
  ping_count: int = 2,
66
74
  timeout: int = 2
@@ -70,20 +78,35 @@ class IPAlive:
70
78
  if os_name == "windows":
71
79
  # -n count, -w timeout in ms
72
80
  cmd = ['ping', '-n', str(ping_count), '-w', str(timeout*1000)]
73
- else:
81
+ else: # Linux, macOS, and other Unix-like systems
74
82
  # -c count, -W timeout in s
75
83
  cmd = ['ping', '-c', str(ping_count), '-W', str(timeout)]
76
84
 
85
+ cmd = cmd + [host]
86
+
77
87
  for r in range(retries):
78
88
  try:
79
- output = subprocess.check_output(
80
- cmd + [host],
81
- stderr=subprocess.STDOUT,
82
- universal_newlines=True
89
+ proc = subprocess.run(
90
+ cmd,
91
+ text=True,
92
+ stdout=subprocess.PIPE,
93
+ stderr=subprocess.PIPE
83
94
  )
84
- # Windows/Linux both include “TTL” on a successful reply
85
- if 'TTL' in output.upper():
86
- return True
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
87
110
  except subprocess.CalledProcessError as e:
88
111
  self.caught_errors.append(DeviceError(e))
89
112
  pass
@@ -205,9 +228,9 @@ mac_selector = MacSelector()
205
228
 
206
229
  def get_ip_address(interface: str):
207
230
  """
208
- Get the IP address of a network interface on Windows or Linux.
231
+ Get the IP address of a network interface on Windows, Linux, or macOS.
209
232
  """
210
- def linux():
233
+ def unix_like(): # Combined Linux and macOS
211
234
  try:
212
235
  import fcntl
213
236
  import struct
@@ -233,17 +256,15 @@ def get_ip_address(interface: str):
233
256
  # Call the appropriate function based on the platform
234
257
  if psutil.WINDOWS:
235
258
  return windows()
236
- elif psutil.LINUX:
237
- return linux()
238
- else:
239
- return None
259
+ else: # Linux, macOS, and other Unix-like systems
260
+ return unix_like()
240
261
 
241
262
  def get_netmask(interface: str):
242
263
  """
243
264
  Get the netmask of a network interface.
244
265
  """
245
266
 
246
- def linux():
267
+ def unix_like(): # Combined Linux and macOS
247
268
  try:
248
269
  import fcntl
249
270
  sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@@ -267,7 +288,8 @@ def get_netmask(interface: str):
267
288
 
268
289
  if psutil.WINDOWS:
269
290
  return windows()
270
- return linux()
291
+ else: # Linux, macOS, and other Unix-like systems
292
+ return unix_like()
271
293
 
272
294
  def get_cidr_from_netmask(netmask: str):
273
295
  """
@@ -278,20 +300,65 @@ def get_cidr_from_netmask(netmask: str):
278
300
 
279
301
  def get_primary_interface():
280
302
  """
281
- Get the primary network interface based on the default gateway.
303
+ Get the primary network interface that is likely handling internet traffic.
304
+ Uses heuristics to identify the most probable interface.
282
305
  """
283
- # Get the default gateway information
284
- gateways = psutil.net_if_addrs()
285
- default_gw = psutil.net_if_stats()
286
-
287
- # Iterate over the default gateways
288
- for interface, addrs in gateways.items():
289
- if default_gw[interface].isup: # Ensure the interface is up
290
- for addr in addrs:
291
- if addr.family == socket.AF_INET: # Look for IPv4 addresses
292
- return interface
293
-
294
- return None
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
295
362
 
296
363
  def get_host_ip_mask(ip_with_cidr: str):
297
364
  """
@@ -301,25 +368,26 @@ def get_host_ip_mask(ip_with_cidr: str):
301
368
  network = ipaddress.ip_network(ip_with_cidr, strict=False)
302
369
  return f'{network.network_address}/{cidr}'
303
370
 
304
- def get_network_subnet(interface = get_primary_interface()):
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.
305
376
  """
306
- Get the network interface and subnet.
307
- Default is primary interface
308
- """
377
+ interface = interface or get_primary_interface()
378
+
309
379
  try:
310
- ip_address = get_ip_address(interface)
311
- netmask = get_netmask(interface)
312
- # is valid interface?
313
- if ip_address and netmask:
314
- cidr = get_cidr_from_netmask(netmask)
315
-
316
- ip_mask = f'{ip_address}/{cidr}'
317
-
318
- return get_host_ip_mask(ip_mask)
319
- except:
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:
320
388
  log.info(f'Unable to parse subnet for interface: {interface}')
321
389
  log.debug(traceback.format_exc())
322
- return
390
+ return None
323
391
 
324
392
  def get_all_network_subnets():
325
393
  """
@@ -332,7 +400,9 @@ def get_all_network_subnets():
332
400
  for interface, snicaddrs in addrs.items():
333
401
  for snicaddr in snicaddrs:
334
402
  if snicaddr.family == socket.AF_INET and gateways[interface].isup:
335
- subnet = get_network_subnet(interface)
403
+
404
+ subnet = network_from_snicaddr(snicaddr)
405
+
336
406
  if subnet:
337
407
  subnets.append({
338
408
  'subnet': subnet,
@@ -341,17 +411,70 @@ def get_all_network_subnets():
341
411
 
342
412
  return subnets
343
413
 
344
- def smart_select_primary_subnet(subnets: List[dict]=get_all_network_subnets()) -> str:
414
+ def network_from_snicaddr(snicaddr: psutil._common.snicaddr) -> str:
345
415
  """
346
- Finds the largest subnet within max ip range
347
- not perfect, but works better than subnets[0]
416
+ Convert a psutil snicaddr object to a human-readable string.
348
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)
349
455
  selected = {}
350
456
  for subnet in subnets:
351
- if selected.get('address_cnt',0) < subnet['address_cnt'] < MAX_IPS_ALLOWED:
457
+ if selected.get("address_cnt", 0) < subnet["address_cnt"] < MAX_IPS_ALLOWED:
352
458
  selected = subnet
353
- if not selected and len(subnets):
459
+
460
+ # Third priority: Just take the first subnet if nothing else matched
461
+ if not selected and subnets:
354
462
  selected = subnets[0]
355
- return selected['subnet']
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
356
479
 
357
480
 
@@ -10,6 +10,7 @@ class RuntimeArgs:
10
10
  logfile: bool = False
11
11
  loglevel: str = 'INFO'
12
12
  flask_logging: bool = False
13
+ persistent: bool = False
13
14
 
14
15
  def parse_args() -> RuntimeArgs:
15
16
  parser = argparse.ArgumentParser(description='LANscape')
@@ -19,6 +20,7 @@ def parse_args() -> RuntimeArgs:
19
20
  parser.add_argument('--logfile', action='store_true', help='Log output to lanscape.log')
20
21
  parser.add_argument('--loglevel', default='INFO', help='Set the log level')
21
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')
22
24
 
23
25
  # Parse the arguments
24
26
  args = parser.parse_args()
@@ -12,7 +12,7 @@ from tabulate import tabulate
12
12
  from dataclasses import dataclass
13
13
  from concurrent.futures import ThreadPoolExecutor, as_completed
14
14
 
15
- from .net_tools import Device
15
+ from .net_tools import Device, is_arp_supported
16
16
  from .ip_parser import parse_ip_input
17
17
  from .port_manager import PortManager
18
18
  from.errors import SubnetScanTerminationFailure
@@ -80,6 +80,8 @@ class SubnetScanner:
80
80
  self.uid = str(uuid.uuid4())
81
81
  self.results = ScannerResults(self)
82
82
  self.log: logging.Logger = logging.getLogger('SubnetScanner')
83
+ if not is_arp_supported():
84
+ self.log.warning('ARP is not supported with the active runtime context. Device discovery will be limited to ping responses.')
83
85
  self.log.debug(f'Instantiated with uid: {self.uid}')
84
86
  self.log.debug(f'Port Count: {len(self.ports)} | Device Count: {len(self.subnet)}')
85
87
 
@@ -33,7 +33,7 @@ def lookup_latest_version(package=PACKAGE):
33
33
  no_cache = f'?cachebust={randint(0,6969)}'
34
34
  url = f"https://pypi.org/pypi/{package}/json{no_cache}"
35
35
  try:
36
- response = requests.get(url)
36
+ response = requests.get(url,timeout=5)
37
37
  response.raise_for_status() # Raise an exception for HTTP errors
38
38
  latest = response.json()['info']['version']
39
39
  log.debug(f'Latest pypi version: {latest}')
@@ -14,7 +14,7 @@ import webbrowser
14
14
  import logging
15
15
  import re
16
16
  import time
17
- import traceback
17
+ from typing import Optional
18
18
  from ..ui.app import app
19
19
 
20
20
  log = logging.getLogger('WebBrowser')
@@ -34,7 +34,8 @@ def open_webapp(url: str) -> bool:
34
34
  raise RuntimeError('Unable to find browser binary')
35
35
  log.debug(f'Opening {url} with {exe}')
36
36
 
37
- subprocess.run(f'{exe} --app="{url}"')
37
+ cmd = f'"{exe}" --app="{url}"'
38
+ subprocess.run(cmd, check=True, shell=True)
38
39
 
39
40
  if time.time() - start < 2:
40
41
  log.debug(f'Unable to hook into closure of UI, listening for flask shutdown')
@@ -56,7 +57,7 @@ def open_webapp(url: str) -> bool:
56
57
  return False
57
58
 
58
59
 
59
- def get_default_browser_executable() -> str | None:
60
+ def get_default_browser_executable() -> Optional[str]:
60
61
  if sys.platform.startswith("win"):
61
62
  try:
62
63
  import winreg
@@ -117,8 +118,24 @@ def get_default_browser_executable() -> str | None:
117
118
  # strip arguments like “%u”, “--flag”, etc.
118
119
  exec_cmd = exec_cmd.split()[0]
119
120
  exec_cmd = exec_cmd.split("%")[0]
120
- return exec_cmd
121
+ return exec_cmd
121
122
  return None
122
123
 
124
+ elif sys.platform.startswith("darwin"):
125
+ # macOS: try to find Chrome first for app mode support, fallback to default
126
+ try:
127
+ p = subprocess.run(
128
+ ["mdfind", "kMDItemCFBundleIdentifier == 'com.google.Chrome'"],
129
+ capture_output=True, text=True, check=True
130
+ )
131
+ chrome_paths = p.stdout.strip().split('\n')
132
+ if chrome_paths and chrome_paths[0]:
133
+ return f"{chrome_paths[0]}/Contents/MacOS/Google Chrome"
134
+ except subprocess.CalledProcessError:
135
+ pass
136
+
137
+ # Fallback to system default
138
+ return "/usr/bin/open"
139
+
123
140
  else:
124
141
  raise NotImplementedError(f"Unsupported platform: {sys.platform!r}")
@@ -90,7 +90,6 @@ class ApiTestCase(unittest.TestCase):
90
90
  ]
91
91
  for uri in uris:
92
92
  response = self.app.get(uri)
93
- print(uri, response.status_code)
94
93
  self.assertEqual(response.status_code,200)
95
94
 
96
95
 
@@ -2,6 +2,7 @@ import unittest
2
2
 
3
3
  from ..libraries.version_manager import lookup_latest_version
4
4
  from ..libraries.app_scope import ResourceManager, is_local_run
5
+ from ..libraries.net_tools import is_arp_supported
5
6
 
6
7
 
7
8
 
@@ -20,5 +21,10 @@ class EnvTestCase(unittest.TestCase):
20
21
  def test_local_version(self):
21
22
  self.assertTrue(is_local_run())
22
23
 
24
+ def test_arp_support(self):
25
+ arp_supported = is_arp_supported()
26
+ self.assertIn(arp_supported, [True, False],
27
+ f"ARP support should be either True or False, not {arp_supported}"
28
+ )
23
29
 
24
30
 
lanscape/ui/app.py CHANGED
@@ -1,4 +1,4 @@
1
- from flask import Flask, render_template
1
+ from flask import Flask, render_template, request
2
2
  from time import sleep
3
3
  import multiprocessing
4
4
  import traceback
@@ -9,6 +9,7 @@ import os
9
9
  from ..libraries.runtime_args import RuntimeArgs, parse_args
10
10
  from ..libraries.version_manager import is_update_available, get_installed_version, lookup_latest_version
11
11
  from ..libraries.app_scope import is_local_run
12
+ from ..libraries.net_tools import is_arp_supported
12
13
 
13
14
  app = Flask(
14
15
  __name__
@@ -57,16 +58,28 @@ set_global_safe('update_available', is_update_available)
57
58
  set_global_safe('latest_version',lookup_latest_version)
58
59
  set_global_safe('runtime_args', vars(parse_args()))
59
60
  set_global_safe('is_local',is_local_run)
61
+ set_global_safe('is_arp_supported', is_arp_supported)
60
62
 
61
63
  ## External hook to kill flask server
62
64
  ################################
63
65
 
64
66
  exiting = False
65
- @app.route("/shutdown")
67
+ @app.route("/shutdown", methods=['GET', 'POST'])
66
68
  def exit_app():
69
+
70
+ req_type = request.args.get('type')
71
+ if req_type == 'browser-close':
72
+ args = parse_args()
73
+ if args.persistent:
74
+ log.info('Dectected browser close, not exiting flask.')
75
+ return "Ignored"
76
+ log.info('Web browser closed, terminating flask. (disable with --peristent)')
77
+ elif req_type == 'core':
78
+ log.info('Core requested exit, terminating flask.')
79
+ else:
80
+ log.info('Received external exit request. Terminating flask.')
67
81
  global exiting
68
82
  exiting = True
69
- log.info('Received external exit request. Terminating flask.')
70
83
  return "Done"
71
84
 
72
85
  @app.teardown_request
@@ -89,7 +102,7 @@ def internal_error(e):
89
102
  ## Webserver creation functions
90
103
  ################################
91
104
 
92
- def start_webserver_dameon(args: RuntimeArgs) -> threading.Thread:
105
+ def start_webserver_daemon(args: RuntimeArgs) -> threading.Thread:
93
106
  proc = threading.Thread(target=start_webserver, args=(args,))
94
107
  proc.daemon = True # Kill thread when main thread exits
95
108
  proc.start()
lanscape/ui/main.py CHANGED
@@ -7,12 +7,13 @@ import os
7
7
  from ..libraries.logger import configure_logging
8
8
  from ..libraries.runtime_args import parse_args, RuntimeArgs
9
9
  from ..libraries.web_browser import open_webapp
10
+ from ..libraries.net_tools import is_arp_supported
10
11
  # do this so any logs generated on import are displayed
11
12
  args = parse_args()
12
13
  configure_logging(args.loglevel, args.logfile, args.flask_logging)
13
14
 
14
15
  from ..libraries.version_manager import get_installed_version, is_update_available
15
- from .app import start_webserver_dameon, start_webserver
16
+ from .app import start_webserver_daemon, start_webserver
16
17
  import socket
17
18
 
18
19
 
@@ -43,8 +44,10 @@ def _main():
43
44
  log.info('Flask reloaded app.')
44
45
 
45
46
  args.port = get_valid_port(args.port)
46
-
47
-
47
+
48
+ if not is_arp_supported():
49
+ log.warning('ARP is not supported, device discovery is degraded. For more information, see the help guide: https://github.com/mdennis281/LANscape/blob/main/support/arp-issues.md')
50
+
48
51
  try:
49
52
  start_webserver_ui(args)
50
53
  log.info('Exiting...')
@@ -100,13 +103,13 @@ def start_webserver_ui(args: RuntimeArgs):
100
103
  ).start()
101
104
  start_webserver(args)
102
105
  else:
103
- flask_thread = start_webserver_dameon(args)
106
+ flask_thread = start_webserver_daemon(args)
104
107
  app_closed = open_browser(uri)
105
108
 
106
109
  # depending on env, open_browser may or
107
110
  # may not be coupled with the closure of UI
108
111
  # (if in browser tab, it's uncoupled)
109
- if not app_closed:
112
+ if not app_closed or args.persistent:
110
113
  # not doing a direct join so i can still
111
114
  # terminate the app with ctrl+c
112
115
  while flask_thread.is_alive():
@@ -126,7 +129,7 @@ def get_valid_port(port: int):
126
129
  def terminate():
127
130
  import requests
128
131
  log.info('Attempting flask shutdown')
129
- requests.get(f'http://127.0.0.1:{args.port}/shutdown')
132
+ requests.get(f'http://127.0.0.1:{args.port}/shutdown?type=core')
130
133
 
131
134
 
132
135
 
@@ -11,6 +11,8 @@
11
11
  --danger-accent: #ff5252; /* Bright red for warnings */
12
12
  --danger-accent-hover: #e64545;
13
13
 
14
+ --danger-accent-transparent: rgba(255, 82, 82, 0.3);/* Light red for subtle danger indication */
15
+
14
16
  --warning-accent: #cc8801; /* Vibrant amber for warnings */
15
17
  --warning-accent-hover: #d08802;
16
18
 
@@ -54,8 +56,9 @@ body:has(.submodule) footer {
54
56
  background-color: var(--primary-bg);
55
57
  border-radius: 8px;
56
58
  box-shadow: 0 0 10px var(--box-shadow);
57
- width: 90%;
59
+ width: 95%;
58
60
  margin-top: 10px;
61
+ overflow: hidden;
59
62
  }
60
63
 
61
64
  #header {
@@ -255,18 +258,7 @@ details {
255
258
 
256
259
 
257
260
 
258
- @media screen and (max-width: 681px) {
259
- #power-button {
260
- left: auto;
261
- right: 0;
262
- border-width: 0 0 1px 1px;
263
- border-radius: 0 0 0 5px;
264
- }
265
- .container-fluid {
266
- width:98%;
267
- padding: 8px;
268
- }
269
- }
261
+
270
262
 
271
263
  /* Card Styles */
272
264
  .card {
@@ -692,7 +684,36 @@ html {
692
684
  background-color: var(--warning-accent);
693
685
  }
694
686
 
695
- /* width of iFrame, not page */
687
+ #arp-error {
688
+ width: calc(100% + 40px);
689
+ position: relative;
690
+ display: flex;
691
+ justify-content: center;
692
+ background-color: var(--danger-accent-transparent);
693
+ color: var(--text-color);
694
+ transform: translate3d(-20px, -20px, 0);
695
+ font-size: small;
696
+ }
697
+ #arp-error span {
698
+ text-align: center;
699
+ }
700
+
701
+ @media screen and (max-width: 681px) {
702
+ #power-button {
703
+ left: auto;
704
+ right: 0;
705
+ border-width: 0 0 1px 1px;
706
+ border-radius: 0 0 0 5px;
707
+ }
708
+ .container-fluid {
709
+ width:98%;
710
+ padding: 8px;
711
+ }
712
+ #arp-error {
713
+ width: calc(100% + 16px);
714
+ transform: translate3d(-8px, -8px, 0);
715
+ }
716
+ }
696
717
 
697
718
  @media screen and (max-width: 885px) {
698
719
  #overview-container .col-4 {
@@ -0,0 +1,42 @@
1
+ // helps flask server know when the browser tab is closed
2
+
3
+
4
+ function sendOnUnload(event = null) {
5
+ const url = '/shutdown?type=browser-close';
6
+ const data = JSON.stringify({ event });
7
+ console.log('sendOnUnload called:', data);
8
+ // (1) Using navigator.sendBeacon
9
+ if (navigator.sendBeacon) {
10
+ const blob = new Blob([data], { type: 'application/json' });
11
+ navigator.sendBeacon(url, blob);
12
+ }
13
+ // (2) Or—you can use fetch with keepalive (supported in modern browsers)
14
+ else {
15
+ fetch(url, {
16
+ method: 'POST',
17
+ body: data,
18
+ headers: { 'Content-Type': 'application/json' },
19
+ keepalive: true
20
+ })
21
+ .catch((err) => {
22
+ // If it fails, there's not much you can do here.
23
+ console.warn('sendOnUnload fetch failed:', err);
24
+ });
25
+ }
26
+ }
27
+
28
+ let hasBeenCalled = false;
29
+
30
+ // When pagehide is called w/ persist=false we want to send our payload.
31
+ // Wont work on all browsers, but should work on most modern ones.
32
+ window.addEventListener('pagehide', (event) => {
33
+ if (!hasBeenCalled && !event.persisted) {
34
+ // persisted = false means page is being discarded, not cached
35
+ const clonedEvent = {
36
+ type: 'pagehide',
37
+ persisted: event.persisted
38
+ };
39
+ sendOnUnload(clonedEvent);
40
+ hasBeenCalled = true;
41
+ }
42
+ });
@@ -3,3 +3,7 @@
3
3
  <script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
4
4
  <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js"></script>
5
5
  <script src="{{ url_for('static', filename='js/core.js') }}"></script>
6
+
7
+ {% if section is not defined %}
8
+ <script src="{{ url_for('static', filename='js/on-tab-close.js') }}"></script>
9
+ {% endif %}
@@ -4,9 +4,11 @@
4
4
  <div id="header">
5
5
  <!-- Header and Scan Submission Inline -->
6
6
  <div class="d-flex justify-content-start align-items-center">
7
- <h1 class="title" onclick="location.href = '/'">
8
- <span>LAN</span>scape
9
- </h1>
7
+ <a href="/" class="text-decoration-none" aria-label="Go to homepage">
8
+ <h1 class="title">
9
+ <span>LAN</span>scape
10
+ </h1>
11
+ </a>
10
12
  </div>
11
13
  </div>
12
14
  <div class="scroll-container" id="content">
@@ -18,7 +20,7 @@
18
20
  ({{app_version}} -> {{latest_version}})
19
21
  </p>
20
22
  <input type="text" readonly class="form-control mb-3 mt-3" value="pip install --upgrade lanscape --no-cache"/>
21
- <a href="https://pypi.org/project/lanscape/" target="_blank"></a>
23
+ <a class="text-decoration-none" href="https://pypi.org/project/lanscape/" target="_blank">
22
24
  <button class="btn btn-primary m-2">PyPi - Lanscape</button>
23
25
  </a>
24
26
  </div>
@@ -33,10 +35,10 @@
33
35
  This project has been a learning journey, & I hope it helps you
34
36
  discover more about your network as well. Enjoy!
35
37
  </p>
36
- <a href="https://github.com/mdennis281/" target="_blank"></a>
37
- <button class="btn btn-primary m-2">Github</button>
38
+ <a href="https://github.com/mdennis281/" class="text-decoration-none" target="_blank">
39
+ <button class="btn btn-primary m-2">GitHub</button>
38
40
  </a>
39
- <a href="https://github.com/mdennis281/LANscape" target="_blank">
41
+ <a href="https://github.com/mdennis281/LANscape" class="text-decoration-none" target="_blank">
40
42
  <button class="btn btn-secondary m-2">Project Repo</button>
41
43
  </a>
42
44
  </div>
@@ -49,6 +49,13 @@
49
49
  </div>
50
50
  <div id="content">
51
51
  <div class="container-fluid my-4">
52
+ <!-- ARP Error -->
53
+ <div id="arp-error" class="{{ 'div-hide' if is_arp_supported else '' }}">
54
+ <span>
55
+ Unable to use ARP lookup. Device discovery is degraded.
56
+ <a target="_blank" href="https://github.com/mdennis281/LANscape/blob/main/support/arp-issues.md">Steps to fix</a>
57
+ </span>
58
+ </div>
52
59
  <!-- Scan Results -->
53
60
  <div id="scan-results" class="div-hide">
54
61
  <div class="d-flex justify-content-between">
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lanscape
3
- Version: 1.3.0a6
3
+ Version: 1.3.1
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
@@ -14,7 +14,7 @@ License-File: LICENSE
14
14
  Requires-Dist: Flask<5.0,>=3.0
15
15
  Requires-Dist: psutil<7.0,>=6.0
16
16
  Requires-Dist: requests<3.0,>=2.32
17
- Requires-Dist: Setuptools<81
17
+ Requires-Dist: setuptools
18
18
  Requires-Dist: scapy<3.0,>=2.3.2
19
19
  Requires-Dist: tabulate==0.9.0
20
20
  Requires-Dist: pytest
@@ -32,7 +32,8 @@ 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)
@@ -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.
@@ -6,13 +6,13 @@ lanscape/libraries/errors.py,sha256=DaercNEZD_tUuXF7KsNk3SD6AqAwT-S7fvzpEybVn08,
6
6
  lanscape/libraries/ip_parser.py,sha256=ElXz3LU5CUYWqKOHEyrj5Y4Iv6OBtoSlbCcxhCsibfQ,2226
7
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=boiccYtpv3v37l239b9jQvYUBgz_ynm0W149ceVCSBU,11473
9
+ lanscape/libraries/net_tools.py,sha256=dUGPwiOudr6I3flMrTyUumpve4pJrt_lST3l8fOOFxs,17117
10
10
  lanscape/libraries/port_manager.py,sha256=fNext3FNfGnGYRZK9RhTEwQ2K0e0YmmMlhK4zVAvoCw,1977
11
- lanscape/libraries/runtime_args.py,sha256=zL8QB_Y69OBGjScytuuyHqdy2XimwpypMSPM3_etz7g,1811
11
+ lanscape/libraries/runtime_args.py,sha256=ICX_JkOmqDQdewZNfRxJb9jMggDw1XqF5CxM9zXZE_Q,1947
12
12
  lanscape/libraries/service_scan.py,sha256=jLU84ZoJnqSQbE30Zly2lm2zHrCGutNXjla1sEvp1hE,1949
13
- lanscape/libraries/subnet_scan.py,sha256=0LW_xdoL-PRp59rJr6r6pSL3LiXEO_SJnjdrgEF_pO8,13120
14
- lanscape/libraries/version_manager.py,sha256=v-IsZ7sYIsNRiraIRckGZthlyL0iTuscR6jF_o9LBK8,1720
15
- lanscape/libraries/web_browser.py,sha256=mMc_48g2_GZ0FdHOUuy63pBgYKIdmEz16WtYd0pzhIM,4324
13
+ lanscape/libraries/subnet_scan.py,sha256=nT1-P9UkIneRcgfPNwZveiYYB4T-qnS6fv21tYRhCGQ,13313
14
+ lanscape/libraries/version_manager.py,sha256=3n0PV_EdW9Fgg2yxD2uzA4WYvzk6H6hMf_1gfvtZbp0,1730
15
+ lanscape/libraries/web_browser.py,sha256=EuKJG3bmBZUochDGm9a0qrB6JkIkOkvx4njZBe4eLIQ,5028
16
16
  lanscape/resources/mac_addresses/convert_csv.py,sha256=w3Heed5z2mHYDEZNBep3_hNg4dbrp_N6J54MGxnrq4s,721
17
17
  lanscape/resources/mac_addresses/mac_db.json,sha256=ygtFSwNwJzDlg6hmAujdgCyzUjxt9Di75J8SO4xYIs8,2187804
18
18
  lanscape/resources/ports/convert_csv.py,sha256=mWe8zucWVfnlNEx_ZzH5Vc3tJJbdi-Ih4nm2yKNrRN0,720
@@ -23,11 +23,11 @@ lanscape/resources/ports/small.json,sha256=Mj3zGVG1F2eqZx2YkrLpTL8STeLcqB8_65IR6
23
23
  lanscape/resources/services/definitions.jsonc,sha256=71w9Q7r4RoBYiIMkzzO2KdEJXaSIchNccYQueqAhD4E,8842
24
24
  lanscape/tests/__init__.py,sha256=xYPeceOF-ppTS0wnq7CkVYQMwupmeSHxbYLbGj_imZ0,113
25
25
  lanscape/tests/_helpers.py,sha256=wXJfUwzL3Fq4XBsC3dValCbXsf0U8FisuM_yo1de4QQ,371
26
- lanscape/tests/test_api.py,sha256=881eHwSHT5GnOB61zbt3BRzCZnSwN2lndwCM5kIHLgo,7359
27
- lanscape/tests/test_env.py,sha256=ivFhCcemJ9vbe0_KtUkbqDY4r9nsDB8rVLUVjV-sNj8,673
26
+ lanscape/tests/test_api.py,sha256=sjcjt3b1ZwvQiALOSfXoQxTlN1Z1_TRPC0EEy-bRIgE,7313
27
+ lanscape/tests/test_env.py,sha256=7gfekAAfzlQxHmYG3MwDQpABDDkRomKWBhmIHFPq0jw,946
28
28
  lanscape/tests/test_library.py,sha256=OPcTsUoR5IureSNDbePxid2BG98mfNNIJmCIY0BVz3w,1553
29
- lanscape/ui/app.py,sha256=Efa9gJnAcA_a3taCne9EHU4YxTCoRDd1M6UGa3rb2Qw,3126
30
- lanscape/ui/main.py,sha256=zDs7epDeXRoxZAHt9aLXB3kypXdZa1pSeqCIitwN7Ig,3998
29
+ lanscape/ui/app.py,sha256=WfoWmNMkt9_58Dn8rX4vs59rchQ8E2d_6vGOvaINYbg,3701
30
+ lanscape/ui/main.py,sha256=oc43T1zS00v1QmS0yI4M1-FmLLW5vtrsWJ9CA4DAeBw,4284
31
31
  lanscape/ui/blueprints/__init__.py,sha256=agvgPOSVbrxddaw6EY64ZZr1CQi1Qzwcs1t0lZMv5oY,206
32
32
  lanscape/ui/blueprints/api/__init__.py,sha256=t0QOq3vHFWmlZm_3YFPQbQzCn1a_a5cmRchtIxwy4eY,103
33
33
  lanscape/ui/blueprints/api/port.py,sha256=2UA38umzXE8pMitx1E-_wJHyL1dYYbtM6Kg5zVtfj6A,1019
@@ -36,7 +36,7 @@ lanscape/ui/blueprints/api/tools.py,sha256=CD0NDSX8kN6_lpl0jEw-ULLsDx7pKODCMFQia
36
36
  lanscape/ui/blueprints/web/__init__.py,sha256=-WRjENG8D99NfaiSDk9uAa8OX6XJq9Zmq1ck29ARL-w,92
37
37
  lanscape/ui/blueprints/web/routes.py,sha256=hl89T5_oRbTlA9Cde_xh9zAQDGRRH-Dpwm74B1_NM1Y,2511
38
38
  lanscape/ui/static/lanscape.webmanifest,sha256=0aauJk_Bybd0B2iwzJfvPcs7AX43kVHs0dtpV6_jSWk,459
39
- lanscape/ui/static/css/style.css,sha256=ZoROzQmBIZlMdz0i9YHkx0Eomo87nQcDLJnGiiC0S3Y,15838
39
+ lanscape/ui/static/css/style.css,sha256=lu9078sZKrMBmT_tM6084_DDnEEMXVMziO6E-gVYkhk,16372
40
40
  lanscape/ui/static/img/ico/android-chrome-192x192.png,sha256=JmFT6KBCCuoyxMV-mLNtF9_QJbVBvfWPUizKN700fi8,18255
41
41
  lanscape/ui/static/img/ico/android-chrome-512x512.png,sha256=88Jjx_1-4XAnZYz64KP6FdTl_kYkNG2_kQIKteQwSh4,138055
42
42
  lanscape/ui/static/img/ico/apple-touch-icon.png,sha256=tEJlLwBZtF4v-NC90YCfRJQ2prTsF4i3VQLK_hnv2Mw,16523
@@ -47,25 +47,26 @@ lanscape/ui/static/img/ico/site.webmanifest,sha256=ep4Hzh9zhmiZF2At3Fp1dQrYQuYF_
47
47
  lanscape/ui/static/js/core.js,sha256=y-f8iQPIetllUY0lSCwnGbPCk5fTJbbU6Pxm3rw1EBU,1111
48
48
  lanscape/ui/static/js/layout-sizing.js,sha256=23UuKdEmRChg6fyqj3DRvcsNfMoa6MRt6dkaT0k7_UY,841
49
49
  lanscape/ui/static/js/main.js,sha256=s0ipGqmuuFHnH9KKoUVaDRJ10_YqYoJ-9r_YnbsH8Mw,7676
50
+ lanscape/ui/static/js/on-tab-close.js,sha256=YYzNd1KMrLWW4-rFcC8EXckTRfG-pKRLA6xpdVTDt04,1417
50
51
  lanscape/ui/static/js/quietReload.js,sha256=_mHzpUsGL4Lm1hNsr3VYSOGVcgGA2y1-eZHacssTXGs,724
51
52
  lanscape/ui/static/js/shutdown-server.js,sha256=WkO7_SNSHM_6kReUoCoExIdCf7Sl7IPiSiNxpbI-r0s,469
52
53
  lanscape/ui/static/js/subnet-info.js,sha256=aytt0LkBx4FVq36TxiMEw3aM7XQLHg_ng1U2WDwZVF4,577
53
54
  lanscape/ui/static/js/subnet-selector.js,sha256=OG01pDaSOPLq3Ial0aO0CqPcob9tPZA1MZKGmQG0W7Q,366
54
55
  lanscape/ui/templates/base.html,sha256=P5xnMlvDXYkYSXdSZUWaRfhsszNuZPP7A56hemBrAFs,1498
55
56
  lanscape/ui/templates/error.html,sha256=zXFO0zPIfQORWq1ZMiSZ8G7FjfhVVr-aaYC0HeBl4Rs,1068
56
- lanscape/ui/templates/info.html,sha256=oCC59keGEfgUB4WaCozaeZEfNb8Nr7y61DmkRBMqs18,2461
57
- lanscape/ui/templates/main.html,sha256=M12xJSN6Ga565vIPhdCiqcr1tYgDrqzuQTeuXtk-8yo,3759
57
+ lanscape/ui/templates/info.html,sha256=1ISKdR0dWoGscS--cY_h65UCdL4ldYd8B4-4k6S3QqI,2614
58
+ lanscape/ui/templates/main.html,sha256=J24jdTYUXoXiqDVAiK8wQeHfznH0l98Z2NAU-KJlSPM,4131
58
59
  lanscape/ui/templates/scan.html,sha256=Fz1Q4CzRq5qpKgszTAQLhaLVV0A6gBraT33mNDmpYRE,390
59
60
  lanscape/ui/templates/shutdown.html,sha256=v0cGT5CJWi-V8b5sUN3l-QIDNUmHTvKGi2gDlhmRlrs,724
60
61
  lanscape/ui/templates/core/head.html,sha256=6XyoDIz-IMPIRG-ti2LFY9FFX39UTIf_K6-6USni_ek,788
61
- lanscape/ui/templates/core/scripts.html,sha256=TRW74VUDasOTFYkaDhKKFnEwHyNx-_rmzSm5nE4n_ys,521
62
+ lanscape/ui/templates/core/scripts.html,sha256=l9qoODOD9VDKMk2-4lsD_gwscHPXgwuDw-SkQXfXylo,649
62
63
  lanscape/ui/templates/scan/export.html,sha256=Qi0m2xJPbC5I2rxzekXjvQ6q9gm2Lr4VJW6riLhIaU0,776
63
64
  lanscape/ui/templates/scan/ip-table-row.html,sha256=ptY24rxJRaA4PEEQRDncaq6Q0ql5RJ87Kn0zKRCzOHw,4842
64
65
  lanscape/ui/templates/scan/ip-table.html,sha256=ds__UP9JiTKf5IxCmTMzw--eN_yg1Pvn3Nj1KvQxeZg,940
65
66
  lanscape/ui/templates/scan/overview.html,sha256=FsX-jSFhGKwCxZGKE8AMKk328UuawN6O9RNTzYvIOts,1205
66
67
  lanscape/ui/templates/scan/scan-error.html,sha256=Q4eZM5ThrxnFaWOSTUpK8hA2ksHwhxOBTaVUCLALhyA,1032
67
- lanscape-1.3.0a6.dist-info/licenses/LICENSE,sha256=cCO-NbS01Ilwc6djHjZ7LIgPFRkRmWdr0fH2ysXKioA,1090
68
- lanscape-1.3.0a6.dist-info/METADATA,sha256=HhwZRp7f-0IwPDHginCcuPUqp5TsdPHJgecFcF4jUN0,2123
69
- lanscape-1.3.0a6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
70
- lanscape-1.3.0a6.dist-info/top_level.txt,sha256=E9D4sjPz_6H7c85Ycy_pOS2xuv1Wm-ilKhxEprln2ps,9
71
- lanscape-1.3.0a6.dist-info/RECORD,,
68
+ lanscape-1.3.1.dist-info/licenses/LICENSE,sha256=cCO-NbS01Ilwc6djHjZ7LIgPFRkRmWdr0fH2ysXKioA,1090
69
+ lanscape-1.3.1.dist-info/METADATA,sha256=fLPrPvQi__d3QVtRbVeULJdHxnJWQbdt_LKjKoVJV-w,2565
70
+ lanscape-1.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
71
+ lanscape-1.3.1.dist-info/top_level.txt,sha256=E9D4sjPz_6H7c85Ycy_pOS2xuv1Wm-ilKhxEprln2ps,9
72
+ lanscape-1.3.1.dist-info/RECORD,,