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.
- {lanscape-1.2.10a1/src/lanscape.egg-info → lanscape-1.3.0}/PKG-INFO +12 -3
- {lanscape-1.2.10a1 → lanscape-1.3.0}/README.md +11 -2
- {lanscape-1.2.10a1 → lanscape-1.3.0}/pyproject.toml +1 -1
- lanscape-1.3.0/src/lanscape/libraries/errors.py +29 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/logger.py +2 -2
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/net_tools.py +76 -40
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/runtime_args.py +4 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/subnet_scan.py +25 -14
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/version_manager.py +10 -6
- lanscape-1.3.0/src/lanscape/libraries/web_browser.py +124 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/tests/test_api.py +42 -1
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/app.py +17 -9
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/blueprints/api/scan.py +3 -1
- lanscape-1.3.0/src/lanscape/ui/blueprints/web/routes.py +74 -0
- lanscape-1.3.0/src/lanscape/ui/main.py +134 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/css/style.css +3 -1
- lanscape-1.3.0/src/lanscape/ui/static/img/ico/android-chrome-192x192.png +0 -0
- lanscape-1.3.0/src/lanscape/ui/static/img/ico/android-chrome-512x512.png +0 -0
- lanscape-1.3.0/src/lanscape/ui/static/img/ico/apple-touch-icon.png +0 -0
- lanscape-1.3.0/src/lanscape/ui/static/img/ico/favicon-16x16.png +0 -0
- lanscape-1.3.0/src/lanscape/ui/static/img/ico/favicon-32x32.png +0 -0
- lanscape-1.3.0/src/lanscape/ui/static/img/ico/favicon.ico +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/js/main.js +38 -4
- lanscape-1.3.0/src/lanscape/ui/static/js/on-tab-close.js +29 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/core/scripts.html +1 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/info.html +2 -2
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/scan/overview.html +3 -2
- {lanscape-1.2.10a1 → lanscape-1.3.0/src/lanscape.egg-info}/PKG-INFO +12 -3
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape.egg-info/SOURCES.txt +2 -0
- lanscape-1.2.10a1/src/lanscape/libraries/errors.py +0 -12
- lanscape-1.2.10a1/src/lanscape/ui/blueprints/web/routes.py +0 -67
- lanscape-1.2.10a1/src/lanscape/ui/main.py +0 -98
- lanscape-1.2.10a1/src/lanscape/ui/static/img/ico/android-chrome-192x192.png +0 -0
- lanscape-1.2.10a1/src/lanscape/ui/static/img/ico/android-chrome-512x512.png +0 -0
- lanscape-1.2.10a1/src/lanscape/ui/static/img/ico/apple-touch-icon.png +0 -0
- lanscape-1.2.10a1/src/lanscape/ui/static/img/ico/favicon-16x16.png +0 -0
- lanscape-1.2.10a1/src/lanscape/ui/static/img/ico/favicon-32x32.png +0 -0
- lanscape-1.2.10a1/src/lanscape/ui/static/img/ico/favicon.ico +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/LICENSE +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/MANIFEST.in +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/setup.cfg +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/__init__.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/__main__.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/app_scope.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/decorators.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/ip_parser.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/mac_lookup.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/port_manager.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/libraries/service_scan.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/mac_addresses/convert_csv.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/mac_addresses/mac_db.json +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/ports/convert_csv.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/ports/full.json +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/ports/large.json +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/ports/medium.json +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/ports/small.json +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/resources/services/definitions.jsonc +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/tests/__init__.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/tests/_helpers.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/tests/test_env.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/tests/test_library.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/blueprints/__init__.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/blueprints/api/__init__.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/blueprints/api/port.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/blueprints/api/tools.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/blueprints/web/__init__.py +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/img/ico/site.webmanifest +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/js/core.js +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/js/layout-sizing.js +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/js/quietReload.js +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/js/shutdown-server.js +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/js/subnet-info.js +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/js/subnet-selector.js +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/static/lanscape.webmanifest +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/base.html +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/core/head.html +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/error.html +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/main.html +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/scan/export.html +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/scan/ip-table-row.html +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/scan/ip-table.html +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/scan/scan-error.html +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/scan.html +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape/ui/templates/shutdown.html +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape.egg-info/dependency_links.txt +0 -0
- {lanscape-1.2.10a1 → lanscape-1.3.0}/src/lanscape.egg-info/requires.txt +0 -0
- {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.
|
|
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:
|
|
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:
|
|
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.
|
|
@@ -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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
+
device.alive = self._ping(device)
|
|
187
190
|
self.results.scanned()
|
|
188
|
-
if not
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|