lanscape 2.2.0a2__tar.gz → 2.3.0a1__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.
Files changed (93) hide show
  1. {lanscape-2.2.0a2/lanscape.egg-info → lanscape-2.3.0a1}/PKG-INFO +4 -2
  2. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/net_tools.py +14 -3
  3. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/service_scan.py +38 -3
  4. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/blueprints/api/port.py +14 -0
  5. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/main.py +11 -7
  6. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/css/style.css +2 -2
  7. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/js/scan-config.js +17 -6
  8. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/config.html +5 -3
  9. {lanscape-2.2.0a2 → lanscape-2.3.0a1/lanscape.egg-info}/PKG-INFO +4 -2
  10. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape.egg-info/SOURCES.txt +1 -1
  11. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape.egg-info/requires.txt +3 -1
  12. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/pyproject.toml +5 -3
  13. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/tests/test_api.py +30 -1
  14. lanscape-2.3.0a1/tests/test_port_scan_linux_fd_exhaustion.py +297 -0
  15. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/tests/test_utils.py +2 -2
  16. lanscape-2.2.0a2/lanscape/core/web_browser.py +0 -210
  17. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/LICENSE +0 -0
  18. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/MANIFEST.in +0 -0
  19. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/README.md +0 -0
  20. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/__init__.py +0 -0
  21. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/__main__.py +0 -0
  22. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/__init__.py +0 -0
  23. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/app_scope.py +0 -0
  24. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/decorators.py +0 -0
  25. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/device_alive.py +0 -0
  26. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/errors.py +0 -0
  27. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/ip_parser.py +0 -0
  28. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/logger.py +0 -0
  29. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/mac_lookup.py +0 -0
  30. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/port_manager.py +0 -0
  31. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/runtime_args.py +0 -0
  32. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/scan_config.py +0 -0
  33. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/subnet_scan.py +0 -0
  34. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/core/version_manager.py +0 -0
  35. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/resources/mac_addresses/convert_csv.py +0 -0
  36. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/resources/mac_addresses/mac_db.json +0 -0
  37. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/resources/ports/convert_csv.py +0 -0
  38. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/resources/ports/full.json +0 -0
  39. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/resources/ports/large.json +0 -0
  40. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/resources/ports/medium.json +0 -0
  41. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/resources/ports/small.json +0 -0
  42. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/resources/ports/test_port_list_scan.json +0 -0
  43. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/resources/services/definitions.jsonc +0 -0
  44. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/__init__.py +0 -0
  45. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/app.py +0 -0
  46. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/blueprints/__init__.py +0 -0
  47. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/blueprints/api/__init__.py +0 -0
  48. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/blueprints/api/scan.py +0 -0
  49. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/blueprints/api/tools.py +0 -0
  50. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/blueprints/web/__init__.py +0 -0
  51. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/blueprints/web/routes.py +0 -0
  52. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/shutdown_handler.py +0 -0
  53. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/android-chrome-192x192.png +0 -0
  54. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/android-chrome-512x512.png +0 -0
  55. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/apple-touch-icon.png +0 -0
  56. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/favicon-16x16.png +0 -0
  57. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/favicon-32x32.png +0 -0
  58. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/favicon.ico +0 -0
  59. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/site.webmanifest +0 -0
  60. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/js/core.js +0 -0
  61. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/js/layout-sizing.js +0 -0
  62. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/js/main.js +0 -0
  63. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/js/on-tab-close.js +0 -0
  64. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/js/quietReload.js +0 -0
  65. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/js/shutdown-server.js +0 -0
  66. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/js/subnet-info.js +0 -0
  67. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/js/subnet-selector.js +0 -0
  68. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/static/lanscape.webmanifest +0 -0
  69. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/base.html +0 -0
  70. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/core/head.html +0 -0
  71. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/core/scripts.html +0 -0
  72. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/error.html +0 -0
  73. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/info.html +0 -0
  74. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/main.html +0 -0
  75. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/device-detail.html +0 -0
  76. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/export.html +0 -0
  77. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/ip-table-row.html +0 -0
  78. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/ip-table.html +0 -0
  79. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/overview.html +0 -0
  80. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/scan-error.html +0 -0
  81. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/scan.html +0 -0
  82. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape/ui/templates/shutdown.html +0 -0
  83. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape.egg-info/dependency_links.txt +0 -0
  84. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape.egg-info/entry_points.txt +0 -0
  85. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/lanscape.egg-info/top_level.txt +0 -0
  86. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/setup.cfg +0 -0
  87. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/tests/test_decorators.py +0 -0
  88. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/tests/test_env.py +0 -0
  89. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/tests/test_globals.py +0 -0
  90. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/tests/test_library.py +0 -0
  91. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/tests/test_logging.py +0 -0
  92. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/tests/test_port_scan.py +0 -0
  93. {lanscape-2.2.0a2 → lanscape-2.3.0a1}/tests/test_service_scan.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lanscape
3
- Version: 2.2.0a2
3
+ Version: 2.3.0a1
4
4
  Summary: A python based local network scanner
5
5
  Author-email: Michael Dennis <michael@dipduo.com>
6
6
  License-Expression: MIT
@@ -25,12 +25,14 @@ Requires-Dist: scapy<3.0,>=2.3.2
25
25
  Requires-Dist: tabulate==0.9.0
26
26
  Requires-Dist: pydantic
27
27
  Requires-Dist: icmplib
28
- Requires-Dist: pwa-launcher>=0.3.0
28
+ Requires-Dist: pwa-launcher>=1.1.0
29
29
  Provides-Extra: dev
30
30
  Requires-Dist: pytest>=8.0; extra == "dev"
31
31
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
32
32
  Requires-Dist: pytest-xdist>=3.0; extra == "dev"
33
33
  Requires-Dist: openai>=1.0.0; extra == "dev"
34
+ Requires-Dist: pylint>=3.0; extra == "dev"
35
+ Requires-Dist: autopep8>=2.0; extra == "dev"
34
36
  Dynamic: license-file
35
37
 
36
38
  # LANscape
@@ -138,18 +138,29 @@ class Device(BaseModel):
138
138
  @timeout_enforcer(enforcer_timeout, False)
139
139
  def do_test():
140
140
  for attempt in range(port_config.retries + 1):
141
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
142
- sock.settimeout(port_config.timeout)
141
+ sock = None
143
142
  try:
143
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
144
+ sock.settimeout(port_config.timeout)
144
145
  result = sock.connect_ex((self.ip, port))
145
146
  if result == 0:
146
147
  if port not in self.ports:
147
148
  self.ports.append(port)
148
149
  return True
150
+ except OSError as e:
151
+ # Handle socket creation failures (e.g., "Too many open files")
152
+ # Log and continue to retry if attempts remain
153
+ log = logging.getLogger('Device.test_port')
154
+ log.debug(f"OSError on {self.ip}:{port} attempt {attempt + 1}: {e}")
149
155
  except Exception:
150
156
  pass # Connection failed, try again if retries remain
151
157
  finally:
152
- sock.close()
158
+ # Always close socket if it was created
159
+ if sock is not None:
160
+ try:
161
+ sock.close()
162
+ except Exception:
163
+ pass # Ignore errors during cleanup
153
164
 
154
165
  # Wait before retry (except on last attempt)
155
166
  if attempt < port_config.retries:
@@ -154,7 +154,9 @@ def get_port_probes(port: int, strategy: ServiceScanStrategy):
154
154
 
155
155
  def scan_service(ip: str, port: int, cfg: ServiceScanConfig) -> str:
156
156
  """
157
- Synchronous function that attempts to identify the service running on a given port.
157
+ Synchronous function that attempts to identify the service
158
+ running on a given port.
159
+ TODO: This is AI slop and needs to be reworked properly.
158
160
  """
159
161
 
160
162
  async def _async_scan_service(
@@ -183,5 +185,38 @@ def scan_service(ip: str, port: int, cfg: ServiceScanConfig) -> str:
183
185
  log.debug(traceback.format_exc())
184
186
  return "Unknown"
185
187
 
186
- # Use asyncio.run to execute the asynchronous logic synchronously
187
- return asyncio.run(_async_scan_service(ip, port, cfg=cfg))
188
+ # Create and properly manage event loop to avoid file descriptor leaks
189
+ # Using new_event_loop + explicit close is safer in threaded environments
190
+ # than asyncio.run() which can leave resources open under heavy load
191
+ loop = None
192
+ try:
193
+ try:
194
+ # Try to get existing loop first (if running in async context)
195
+ loop = asyncio.get_running_loop()
196
+ # If we're already in an async context, just await directly
197
+ return asyncio.run_coroutine_threadsafe(
198
+ _async_scan_service(ip, port, cfg=cfg), loop
199
+ ).result(timeout=cfg.timeout + 5)
200
+ except RuntimeError:
201
+ # No running loop, create a new one
202
+ loop = asyncio.new_event_loop()
203
+ asyncio.set_event_loop(loop)
204
+ try:
205
+ return loop.run_until_complete(_async_scan_service(ip, port, cfg=cfg))
206
+ finally:
207
+ # Clean up the loop properly
208
+ try:
209
+ # Cancel all remaining tasks
210
+ pending = asyncio.all_tasks(loop)
211
+ for task in pending:
212
+ task.cancel()
213
+ # Run loop once more to process cancellations
214
+ if pending:
215
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
216
+ except Exception:
217
+ pass
218
+ finally:
219
+ loop.close()
220
+ except Exception as e:
221
+ log.error(f"Event loop error scanning {ip}:{port}: {e}")
222
+ return "Unknown"
@@ -21,6 +21,20 @@ def get_port_lists():
21
21
  return jsonify(PortManager().get_port_lists())
22
22
 
23
23
 
24
+ @api_bp.route('/api/port/list/summary', methods=['GET'])
25
+ def get_port_lists_summary():
26
+ """Get port list names with their port counts."""
27
+ manager = PortManager()
28
+ summaries = []
29
+ for name in manager.get_port_lists():
30
+ ports = manager.get_port_list(name) or {}
31
+ summaries.append({
32
+ 'name': name,
33
+ 'count': len(ports)
34
+ })
35
+ return jsonify(summaries)
36
+
37
+
24
38
  @api_bp.route('/api/port/list/<port_list>', methods=['GET'])
25
39
  def get_port_list(port_list):
26
40
  """
@@ -1,20 +1,19 @@
1
1
  """Main entry point for the LANscape application when running as a module."""
2
2
  import socket
3
3
 
4
-
5
- import threading
6
4
  import time
7
5
  import logging
8
6
  import traceback
9
7
  import os
10
- import requests
11
8
  from subprocess import Popen
9
+ import webbrowser
10
+ import requests
11
+
12
+ from pwa_launcher import open_pwa, ChromiumNotFoundError
12
13
 
13
- from pwa_launcher import open_pwa
14
14
 
15
15
  from lanscape.core.logger import configure_logging
16
16
  from lanscape.core.runtime_args import parse_args
17
- from lanscape.core.web_browser import open_webapp
18
17
  from lanscape.core.version_manager import get_installed_version, is_update_available
19
18
  from lanscape.ui.app import start_webserver_daemon, start_webserver
20
19
  # do this so any logs generated on import are displayed
@@ -73,7 +72,7 @@ def try_check_update():
73
72
  log.warning('Unable to check for updates.')
74
73
 
75
74
 
76
- def open_browser(url: str, wait=2) -> Popen:
75
+ def open_browser(url: str, wait=2) -> Popen | None:
77
76
  """
78
77
  Open a browser window to the specified
79
78
  url after waiting for the server to start
@@ -83,6 +82,12 @@ def open_browser(url: str, wait=2) -> Popen:
83
82
  log.info(f'Starting UI - http://127.0.0.1:{args.port}')
84
83
  return open_pwa(url)
85
84
 
85
+ except ChromiumNotFoundError:
86
+ success = webbrowser.open(url)
87
+ if success:
88
+ log.warning("Chromium browser not found. Falling back to default web browser.")
89
+ else:
90
+ log.warning(f"Cannot find any web browser. LANScape UI running on {url}")
86
91
  except BaseException:
87
92
  log.debug(traceback.format_exc())
88
93
  log.info(f'Unable to open web browser, server running on {url}')
@@ -105,7 +110,6 @@ def start_webserver_ui():
105
110
  else:
106
111
  flask_thread = start_webserver_daemon(args)
107
112
  proc = open_browser(uri)
108
-
109
113
  if proc:
110
114
  app_closed = proc.wait()
111
115
  else:
@@ -564,8 +564,8 @@ input[type="range"] {
564
564
  /* Service Strategy Select Styles */
565
565
  .service-strategy-wrapper {
566
566
  position: relative;
567
- display: inline-block;
568
- width: 60%; /* Narrower than port list */
567
+ display: block;
568
+ width: 100%;
569
569
  }
570
570
 
571
571
  .service-strategy {
@@ -139,15 +139,26 @@ function getScanConfig() {
139
139
  }
140
140
 
141
141
  function getPortLists(callback=null) {
142
- $.get('/api/port/list', function(data) {
143
- const customSelectDropdown = $('#port_list');
142
+ const customSelectDropdown = $('#port_list');
143
+
144
+ const renderOptions = (items) => {
144
145
  customSelectDropdown.empty();
145
-
146
- // Populate the dropdown with the options
147
- data.forEach(function(portList) {
148
- customSelectDropdown.append('<option>' + portList + '</option>');
146
+ items.forEach((item) => {
147
+ const name = item.name || item;
148
+ const count = item.count;
149
+ const label = count !== undefined ? `${name} (${count} ports)` : name;
150
+ customSelectDropdown.append(`<option value="${name}">${label}</option>`);
149
151
  });
152
+ };
153
+
154
+ $.get('/api/port/list/summary', function(data) {
155
+ renderOptions(data || []);
150
156
  if (callback) callback();
157
+ }).fail(function() {
158
+ $.get('/api/port/list', function(data) {
159
+ renderOptions(data || []);
160
+ if (callback) callback();
161
+ });
151
162
  });
152
163
  }
153
164
 
@@ -201,7 +201,7 @@
201
201
  <div id="section-service-scan" class="form-group mt-2 config-section div-hide">
202
202
  <h6>Service Scanning</h6>
203
203
  <div class="row">
204
- <div class="col">
204
+ <div class="col-12">
205
205
  <label for="service_lookup_type" class="form-label">Strategy</label>
206
206
  <div class="service-strategy-wrapper">
207
207
  <select id="service_lookup_type" class="service-strategy">
@@ -211,11 +211,13 @@
211
211
  </select>
212
212
  </div>
213
213
  </div>
214
- <div class="col">
214
+ </div>
215
+ <div class="row mt-2">
216
+ <div class="col-12 col-md-6">
215
217
  <label for="service_timeout" class="form-label">Timeout (sec)</label>
216
218
  <input type="number" step="0.1" id="service_timeout" class="form-control">
217
219
  </div>
218
- <div class="col">
220
+ <div class="col-12 col-md-6 mt-2 mt-md-0">
219
221
  <label for="service_max_concurrent_probes" class="form-label">Max Concurrent Probes</label>
220
222
  <input type="number" id="service_max_concurrent_probes" class="form-control">
221
223
  </div>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lanscape
3
- Version: 2.2.0a2
3
+ Version: 2.3.0a1
4
4
  Summary: A python based local network scanner
5
5
  Author-email: Michael Dennis <michael@dipduo.com>
6
6
  License-Expression: MIT
@@ -25,12 +25,14 @@ Requires-Dist: scapy<3.0,>=2.3.2
25
25
  Requires-Dist: tabulate==0.9.0
26
26
  Requires-Dist: pydantic
27
27
  Requires-Dist: icmplib
28
- Requires-Dist: pwa-launcher>=0.3.0
28
+ Requires-Dist: pwa-launcher>=1.1.0
29
29
  Provides-Extra: dev
30
30
  Requires-Dist: pytest>=8.0; extra == "dev"
31
31
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
32
32
  Requires-Dist: pytest-xdist>=3.0; extra == "dev"
33
33
  Requires-Dist: openai>=1.0.0; extra == "dev"
34
+ Requires-Dist: pylint>=3.0; extra == "dev"
35
+ Requires-Dist: autopep8>=2.0; extra == "dev"
34
36
  Dynamic: license-file
35
37
 
36
38
  # LANscape
@@ -25,7 +25,6 @@ lanscape/core/scan_config.py
25
25
  lanscape/core/service_scan.py
26
26
  lanscape/core/subnet_scan.py
27
27
  lanscape/core/version_manager.py
28
- lanscape/core/web_browser.py
29
28
  lanscape/resources/mac_addresses/convert_csv.py
30
29
  lanscape/resources/mac_addresses/mac_db.json
31
30
  lanscape/resources/ports/convert_csv.py
@@ -86,5 +85,6 @@ tests/test_globals.py
86
85
  tests/test_library.py
87
86
  tests/test_logging.py
88
87
  tests/test_port_scan.py
88
+ tests/test_port_scan_linux_fd_exhaustion.py
89
89
  tests/test_service_scan.py
90
90
  tests/test_utils.py
@@ -6,10 +6,12 @@ scapy<3.0,>=2.3.2
6
6
  tabulate==0.9.0
7
7
  pydantic
8
8
  icmplib
9
- pwa-launcher>=0.3.0
9
+ pwa-launcher>=1.1.0
10
10
 
11
11
  [dev]
12
12
  pytest>=8.0
13
13
  pytest-cov>=5.0
14
14
  pytest-xdist>=3.0
15
15
  openai>=1.0.0
16
+ pylint>=3.0
17
+ autopep8>=2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lanscape"
3
- version = "2.2.0a2"
3
+ version = "2.3.0a1"
4
4
  authors = [
5
5
  { name="Michael Dennis", email="michael@dipduo.com" },
6
6
  ]
@@ -28,7 +28,7 @@ dependencies = [
28
28
  "tabulate==0.9.0",
29
29
  "pydantic",
30
30
  "icmplib",
31
- "pwa-launcher>=0.3.0"
31
+ "pwa-launcher>=1.1.0"
32
32
  ]
33
33
 
34
34
  [project.optional-dependencies]
@@ -36,7 +36,9 @@ dev = [
36
36
  "pytest>=8.0",
37
37
  "pytest-cov>=5.0",
38
38
  "pytest-xdist>=3.0",
39
- "openai>=1.0.0"
39
+ "openai>=1.0.0",
40
+ "pylint>=3.0",
41
+ "autopep8>=2.0"
40
42
  ]
41
43
  [project.urls]
42
44
  Homepage = "https://github.com/mdennis281/py-lanscape"
@@ -15,6 +15,7 @@ from tests.test_globals import (
15
15
  MIN_EXPECTED_ALIVE_DEVICES
16
16
  )
17
17
  from lanscape.ui.app import app
18
+ import lanscape.ui.blueprints.api.port as port_api
18
19
 
19
20
 
20
21
  @pytest.fixture
@@ -41,7 +42,7 @@ def test_scan_config():
41
42
  return {
42
43
  'subnet': TEST_SUBNET,
43
44
  'port_list': 'test_port_list_scan',
44
- 'lookup_type': ['ICMP','POKE_THEN_ARP'], # Use ICMP for reliable external IP detection
45
+ 'lookup_type': ['ICMP', 'POKE_THEN_ARP'], # Use ICMP for reliable external IP detection
45
46
  'ping_config': {'timeout': 0.8, 'attempts': 2} # Reasonable timeout for external IPs
46
47
  }
47
48
 
@@ -97,6 +98,34 @@ def test_port_lifecycle(api_client, sample_port_list, updated_port_list):
97
98
  response = api_client.delete(f'/api/port/list/{test_list_name}')
98
99
  assert response.status_code == 200
99
100
 
101
+
102
+ def test_port_list_summary(api_client, monkeypatch):
103
+ """Verify port list summary returns names with counts."""
104
+
105
+ class FakePortManager: # pylint: disable=too-few-public-methods
106
+ """Lightweight fake port manager for summary testing."""
107
+ def __init__(self):
108
+ self._lists = {
109
+ 'small': {'80': 'http', '443': 'https'},
110
+ 'custom': {'22': 'ssh'}
111
+ }
112
+
113
+ def get_port_lists(self):
114
+ """Return available list names."""
115
+ return list(self._lists.keys())
116
+
117
+ def get_port_list(self, name):
118
+ """Return a specific port list by name."""
119
+ return self._lists.get(name, {})
120
+
121
+ monkeypatch.setattr(port_api, 'PortManager', FakePortManager)
122
+
123
+ response = api_client.get('/api/port/list/summary')
124
+ assert response.status_code == 200
125
+ data = response.get_json()
126
+ assert isinstance(data, list)
127
+ assert {item['name']: item['count'] for item in data} == {'small': 2, 'custom': 1}
128
+
100
129
  # API Scan Tests
101
130
  ################
102
131
 
@@ -0,0 +1,297 @@
1
+ """
2
+ Tests for file descriptor exhaustion during large port scans.
3
+
4
+ This module tests the scenario where scanning a large number of ports
5
+ can exhaust file descriptors (sockets) on Unix-like systems such as
6
+ Linux and macOS, causing "Too many open files" OSError.
7
+ """
8
+
9
+ import sys
10
+ from unittest.mock import patch, MagicMock
11
+ from concurrent.futures import ThreadPoolExecutor, as_completed
12
+ import time
13
+ import pytest
14
+
15
+ from lanscape.core.net_tools import Device
16
+ from lanscape.core.scan_config import PortScanConfig
17
+
18
+ # Skip all tests in this module on non-Linux platforms
19
+ pytestmark = pytest.mark.skipif(
20
+ sys.platform != "linux",
21
+ reason="File descriptor exhaustion tests are Linux-specific"
22
+ )
23
+
24
+ try:
25
+ import resource
26
+ except ImportError:
27
+ resource = None # Will be skipped anyway on non-Linux
28
+
29
+
30
+ def test_socket_properly_closed_on_success():
31
+ """Test that sockets are properly closed when connection succeeds."""
32
+ device = Device(ip="127.0.0.1")
33
+ config = PortScanConfig(timeout=0.5, retries=0)
34
+
35
+ with patch('socket.socket') as mock_socket_class:
36
+ mock_socket = MagicMock()
37
+ mock_socket_class.return_value = mock_socket
38
+ mock_socket.connect_ex.return_value = 0 # Success
39
+
40
+ device.test_port(80, config)
41
+
42
+ # Verify socket was closed
43
+ mock_socket.close.assert_called_once()
44
+
45
+
46
+ def test_socket_properly_closed_on_failure():
47
+ """Test that sockets are properly closed when connection fails."""
48
+ device = Device(ip="127.0.0.1")
49
+ config = PortScanConfig(timeout=0.5, retries=0)
50
+
51
+ with patch('socket.socket') as mock_socket_class:
52
+ mock_socket = MagicMock()
53
+ mock_socket_class.return_value = mock_socket
54
+ mock_socket.connect_ex.return_value = 1 # Failure
55
+
56
+ device.test_port(54321, config)
57
+
58
+ # Verify socket was closed
59
+ mock_socket.close.assert_called_once()
60
+
61
+
62
+ def test_socket_properly_closed_on_exception():
63
+ """Test that sockets are properly closed when an exception occurs."""
64
+ device = Device(ip="127.0.0.1")
65
+ config = PortScanConfig(timeout=0.5, retries=0)
66
+
67
+ with patch('socket.socket') as mock_socket_class:
68
+ mock_socket = MagicMock()
69
+ mock_socket_class.return_value = mock_socket
70
+ mock_socket.connect_ex.side_effect = Exception("Connection error")
71
+
72
+ device.test_port(54322, config)
73
+
74
+ # Verify socket was closed even with exception
75
+ mock_socket.close.assert_called_once()
76
+
77
+
78
+ def test_socket_properly_closed_with_retries():
79
+ """Test that sockets are properly closed on each retry attempt."""
80
+ device = Device(ip="127.0.0.1")
81
+ config = PortScanConfig(timeout=0.5, retries=2, retry_delay=0.05)
82
+
83
+ with patch('socket.socket') as mock_socket_class:
84
+ # Create multiple mock socket instances
85
+ mock_sockets = [MagicMock() for _ in range(3)]
86
+ mock_socket_class.side_effect = mock_sockets
87
+
88
+ # All attempts fail
89
+ for mock_sock in mock_sockets:
90
+ mock_sock.connect_ex.return_value = 1
91
+
92
+ device.test_port(54323, config)
93
+
94
+ # Verify each socket was closed (initial + 2 retries = 3 total)
95
+ for mock_sock in mock_sockets:
96
+ mock_sock.close.assert_called_once()
97
+
98
+
99
+ def test_socket_closed_on_socket_creation_failure():
100
+ """Test handling when socket creation itself fails (OSError: Too many open files)."""
101
+ device = Device(ip="127.0.0.1")
102
+ config = PortScanConfig(timeout=0.5, retries=1, retry_delay=0.05)
103
+
104
+ with patch('socket.socket') as mock_socket_class:
105
+ # First attempt: socket creation fails with "Too many open files"
106
+ # Second attempt: succeeds
107
+ mock_socket = MagicMock()
108
+ mock_socket.connect_ex.return_value = 1
109
+
110
+ mock_socket_class.side_effect = [
111
+ OSError(24, "Too many open files"),
112
+ mock_socket
113
+ ]
114
+
115
+ # Should not crash, should handle the error gracefully
116
+ result = device.test_port(54324, config)
117
+
118
+ # Should return False since we couldn't connect
119
+ assert result is False
120
+
121
+ # Second socket should have been closed
122
+ mock_socket.close.assert_called_once()
123
+
124
+
125
+ def test_multiple_concurrent_port_scans_socket_cleanup():
126
+ """Test that concurrent port scans properly clean up sockets."""
127
+ device = Device(ip="192.168.1.100")
128
+ config = PortScanConfig(timeout=0.1, retries=0)
129
+ ports = list(range(8000, 8100)) # 100 ports
130
+
131
+ with patch('socket.socket') as mock_socket_class:
132
+ mock_sockets = []
133
+
134
+ def create_mock_socket(*_args, **_kwargs):
135
+ mock_sock = MagicMock()
136
+ mock_sock.connect_ex.return_value = 1 # All closed
137
+ mock_sockets.append(mock_sock)
138
+ return mock_sock
139
+
140
+ mock_socket_class.side_effect = create_mock_socket
141
+
142
+ # Scan all ports concurrently (simulating real scenario)
143
+ with ThreadPoolExecutor(max_workers=20) as executor:
144
+ futures = [executor.submit(device.test_port, port, config) for port in ports]
145
+ for future in as_completed(futures):
146
+ future.result()
147
+
148
+ # Verify all sockets were closed
149
+ assert len(mock_sockets) == 100
150
+ for mock_sock in mock_sockets:
151
+ mock_sock.close.assert_called_once()
152
+
153
+
154
+ def test_socket_cleanup_with_high_fd_limit():
155
+ """Test socket cleanup when approaching file descriptor limits."""
156
+ device = Device(ip="192.168.1.100")
157
+ config = PortScanConfig(timeout=0.1, retries=1, retry_delay=0.05)
158
+
159
+ # Simulate a scenario where we're near the FD limit
160
+ with patch('socket.socket') as mock_socket_class:
161
+ call_count = 0
162
+ max_calls_before_error = 50
163
+
164
+ def socket_with_limit(*_args, **_kwargs):
165
+ nonlocal call_count
166
+ call_count += 1
167
+
168
+ if call_count >= max_calls_before_error:
169
+ # Simulate "too many open files" error
170
+ raise OSError(24, "Too many open files")
171
+
172
+ mock_sock = MagicMock()
173
+ mock_sock.connect_ex.return_value = 1
174
+ return mock_sock
175
+
176
+ mock_socket_class.side_effect = socket_with_limit
177
+
178
+ # Scan ports until we hit the limit
179
+ ports_scanned = 0
180
+ for port in range(8000, 8200):
181
+ try:
182
+ device.test_port(port, config)
183
+ ports_scanned += 1
184
+ except OSError:
185
+ # Should handle gracefully
186
+ break
187
+
188
+ # Should have scanned some ports before hitting limit
189
+ assert ports_scanned > 0
190
+ # Should not have crashed the entire scan
191
+
192
+
193
+ def test_socket_error_handling_retries():
194
+ """Test that socket errors during retries are handled properly."""
195
+ device = Device(ip="192.168.1.100")
196
+ config = PortScanConfig(timeout=0.5, retries=2, retry_delay=0.05)
197
+
198
+ with patch('socket.socket') as mock_socket_class:
199
+ # First attempt: Too many open files
200
+ # Second attempt: Connection timeout
201
+ # Third attempt: Success
202
+ mock_sock2 = MagicMock()
203
+ mock_sock3 = MagicMock()
204
+
205
+ mock_socket_class.side_effect = [
206
+ OSError(24, "Too many open files"),
207
+ mock_sock2,
208
+ mock_sock3
209
+ ]
210
+
211
+ mock_sock2.connect_ex.return_value = 1 # Fail
212
+ mock_sock3.connect_ex.return_value = 0 # Success
213
+
214
+ result = device.test_port(80, config)
215
+
216
+ # Should succeed on third attempt
217
+ assert result is True
218
+
219
+ # Both successful socket creations should have been closed
220
+ mock_sock2.close.assert_called_once()
221
+ mock_sock3.close.assert_called_once()
222
+
223
+
224
+ def test_context_manager_style_socket_would_be_better():
225
+ """
226
+ This test documents that using 'with' statement for sockets would be better.
227
+ This is more of a design verification test.
228
+ """
229
+ device = Device(ip="127.0.0.1")
230
+ config = PortScanConfig(timeout=0.5, retries=0)
231
+
232
+ with patch('socket.socket') as mock_socket_class:
233
+ mock_socket = MagicMock()
234
+ mock_socket_class.return_value = mock_socket
235
+
236
+ # Simulate that the socket supports context manager
237
+ mock_socket.__enter__ = MagicMock(return_value=mock_socket)
238
+ mock_socket.__exit__ = MagicMock(return_value=None)
239
+ mock_socket.connect_ex.return_value = 0
240
+
241
+ device.test_port(80, config)
242
+
243
+ # Verify close was called
244
+ # (In the future, if we switch to 'with socket.socket()...',
245
+ # __exit__ would be called automatically)
246
+ mock_socket.close.assert_called()
247
+
248
+
249
+ @pytest.mark.skipif(
250
+ not resource or resource.getrlimit(resource.RLIMIT_NOFILE)[0] > 10000,
251
+ reason="Only run on Linux systems with reasonable FD limits"
252
+ )
253
+ def test_real_socket_exhaustion_scenario():
254
+ """
255
+ Test with real sockets to verify the fix handles actual FD exhaustion.
256
+ This test creates many sockets to approach system limits.
257
+ """
258
+ device = Device(ip="192.168.254.254") # Non-existent IP to ensure timeout
259
+ config = PortScanConfig(timeout=0.01, retries=0) # Very short timeout
260
+
261
+ # Get current FD limit
262
+ soft_limit, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
263
+
264
+ # Try to scan many ports and collect results
265
+ results = []
266
+ for port in range(1, min(200, soft_limit // 10)):
267
+ result = device.test_port(port, config)
268
+ results.append(result)
269
+
270
+ # All results should be boolean flags (success/failure), and no OSError
271
+ # should have propagated out of test_port during the scan.
272
+ assert all(isinstance(r, bool) for r in results)
273
+ # For a non-existent IP, we expect all failures since the IP doesn't exist
274
+ assert all(not r for r in results)
275
+
276
+
277
+ def test_socket_cleanup_in_timeout_enforcer():
278
+ """Test that sockets are cleaned up even when timeout_enforcer kills the function."""
279
+ device = Device(ip="192.168.1.100")
280
+ # Very short enforcer timeout to trigger timeout: 0.1 * 1 * 1.5 = 0.15s enforcer
281
+ config = PortScanConfig(timeout=0.1, retries=0)
282
+
283
+ with patch('socket.socket') as mock_socket_class:
284
+ mock_socket = MagicMock()
285
+ mock_socket_class.return_value = mock_socket
286
+
287
+ # Make connect_ex hang longer than timeout (simulate very slow network)
288
+ def slow_connect(*_args):
289
+ time.sleep(0.5) # Sleep longer than 0.15s enforcer timeout
290
+ return 1
291
+
292
+ mock_socket.connect_ex.side_effect = slow_connect
293
+
294
+ device.test_port(54325, config)
295
+
296
+ # Socket should still be closed
297
+ mock_socket.close.assert_called()
@@ -252,9 +252,9 @@ def test_is_internal_block_edge_cases():
252
252
  ('192.168.1.1-192.168.1.10', True, 'Private range'),
253
253
  ('1.1.1.1-1.1.1.5', False, 'Public range'),
254
254
  ('192.168.1.1,10.0.0.1', True, 'Multiple private'),
255
- ('192.168.1.1, 10.0.0.1', True, 'Multiple private (comma with space)'),
255
+ ('192.168.1.1, 10.0.0.1', True, 'Multiple private (comma with space)'),
256
256
  ('192.168.1.1,8.8.8.8', False, 'Mixed private/public'),
257
- ('192.168.1.1, 8.8.8.8', False, 'Mixed private/public (comma with space)'),
257
+ ('192.168.1.1, 8.8.8.8', False, 'Mixed private/public (comma with space)'),
258
258
  ('192.168.1.1', True, 'Single private IP'),
259
259
  ('8.8.8.8', False, 'Single public IP'),
260
260
  ('invalid', False, 'Invalid input'),
@@ -1,210 +0,0 @@
1
- """
2
- Get the executable path of the system’s default web browser.
3
-
4
- Supports:
5
- - Windows (reads from the registry)
6
- - Linux (uses xdg-mime / xdg-settings + .desktop file parsing)
7
- """
8
-
9
- import sys
10
- import os
11
- import subprocess
12
- import webbrowser
13
- import logging
14
- import re
15
- import time
16
- from typing import Optional
17
-
18
- log = logging.getLogger('WebBrowser')
19
-
20
-
21
- def open_webapp(url: str) -> bool:
22
- """
23
- will try to open the web page as an app
24
- on failure, will open as a tab in default browser
25
-
26
- returns:
27
- """
28
- start = time.time()
29
- try:
30
- exe = get_default_browser_executable()
31
- if not exe:
32
- raise RuntimeError('Unable to find browser binary')
33
- log.debug(f'Opening {url} with {exe}')
34
-
35
- cmd = f'"{exe}" --app="{url}"'
36
- subprocess.run(cmd, check=True, shell=True)
37
-
38
- if time.time() - start < 2:
39
- log.debug(
40
- '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(
46
- 'Failed to open webpage as app, falling back to browser tab')
47
- log.debug(f'As app error: {e}')
48
- try:
49
- success = webbrowser.open(url)
50
- log.debug(f'Opened {url} in browser tab: {success}')
51
- if not success:
52
- # pylint: disable=raise-missing-from
53
- raise RuntimeError(
54
- 'Unknown error while opening browser tab') from e
55
- except Exception as e2:
56
- log.warning(
57
- 'Exhausted all options to open browser, you need to open manually')
58
- log.debug(f'As tab error: {e2}')
59
- log.info(f'LANScape UI is running on {url}')
60
- return False
61
-
62
-
63
- def get_default_browser_executable() -> Optional[str]:
64
- """Platform-agnostic method to get the default browser executable path."""
65
- if sys.platform.startswith("win"):
66
- return windows_get_browser_from_registry()
67
-
68
- if sys.platform.startswith("linux"):
69
- return linux_get_browser_executable()
70
-
71
- if sys.platform.startswith("darwin"):
72
- # macOS: try to find Chrome first for app mode support, fallback to default
73
- try:
74
- p = subprocess.run(
75
- ["mdfind", "kMDItemCFBundleIdentifier == 'com.google.Chrome'"],
76
- capture_output=True, text=True, check=True
77
- )
78
- chrome_paths = p.stdout.strip().split('\n')
79
- if chrome_paths and chrome_paths[0]:
80
- return f"{chrome_paths[0]}/Contents/MacOS/Google Chrome"
81
- except subprocess.CalledProcessError:
82
- pass
83
-
84
- # Fallback to system default
85
- return "/usr/bin/open"
86
-
87
- # Unsupported platform
88
- return None
89
-
90
-
91
- def linux_get_browser_executable() -> Optional[str]:
92
- """Get the default web browser executable path on Linux."""
93
- # First, find the .desktop file name
94
- desktop_file = None
95
- try:
96
- # Try xdg-mime
97
- p = subprocess.run(
98
- ["xdg-mime", "query", "default", "x-scheme-handler/http"],
99
- capture_output=True, text=True,
100
- check=True
101
- )
102
- desktop_file = p.stdout.strip()
103
- except subprocess.CalledProcessError:
104
- pass
105
-
106
- if not desktop_file:
107
- # Fallback to xdg-settings
108
- try:
109
- p = subprocess.run(
110
- ["xdg-settings", "get", "default-web-browser"],
111
- capture_output=True, text=True,
112
- check=True
113
- )
114
- desktop_file = p.stdout.strip()
115
- except subprocess.CalledProcessError:
116
- pass
117
-
118
- # Final fallback: BROWSER environment variable
119
- if not desktop_file:
120
- return os.environ.get("BROWSER")
121
-
122
- # Look for that .desktop file in standard locations
123
- search_paths = [
124
- os.path.expanduser("~/.local/share/applications"),
125
- "/usr/local/share/applications",
126
- "/usr/share/applications",
127
- ]
128
-
129
- exec_cmd = None
130
- for path in search_paths:
131
- full_path = os.path.join(path, desktop_file)
132
- if os.path.isfile(full_path):
133
- with open(full_path, encoding="utf-8", errors="ignore") as f:
134
- for line in f:
135
- if line.startswith("Exec="):
136
- exec_cmd = line[len("Exec="):].strip()
137
- # strip arguments like "%u", "--flag", etc.
138
- exec_cmd = exec_cmd.split()[0]
139
- exec_cmd = exec_cmd.split("%")[0]
140
- return exec_cmd
141
-
142
- return exec_cmd
143
-
144
-
145
- def windows_get_browser_from_registry() -> Optional[str]:
146
- """Get the default web browser executable path on Windows."""
147
- # Import winreg only on Windows platforms
148
- if not sys.platform.startswith("win"):
149
- return None
150
-
151
- try:
152
- import winreg # pylint: disable=import-outside-toplevel
153
- except ImportError:
154
- log.debug("winreg module not available")
155
- return None
156
-
157
- def get_reg(base, path, key=None):
158
- """Helper function to read a registry key."""
159
- try:
160
- with winreg.OpenKey(base, path) as reg:
161
- return winreg.QueryValueEx(reg, key)[0]
162
- except FileNotFoundError:
163
- return None
164
-
165
- def extract_executable(cmd: str) -> Optional[str]:
166
- """Extract the executable path from a command string."""
167
- match = re.match(r'"?([^"]+)"?', cmd)
168
- return match.group(1) if match else None
169
-
170
- def get_user_preferred_browser():
171
- """Get the user preferred browser from the registry."""
172
- progid = get_reg(
173
- winreg.HKEY_CURRENT_USER,
174
- r'Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice',
175
- 'ProgId'
176
- )
177
- if not progid:
178
- log.debug('No user preferred browser found in registry')
179
- return None
180
-
181
- browser_path = get_reg(
182
- winreg.HKEY_CLASSES_ROOT,
183
- f'{progid}\\shell\\open\\command'
184
- )
185
-
186
- if not browser_path:
187
- log.debug(f'progid {progid} does not have a command in registry')
188
- return None
189
-
190
- return extract_executable(browser_path)
191
-
192
- def get_system_default_browser():
193
- """Get the system default browser from the registry."""
194
- reg = get_reg(
195
- winreg.HKEY_CLASSES_ROOT,
196
- r'http\shell\open\command'
197
- )
198
- if not reg:
199
- log.debug('No system default browser found in registry')
200
- return None
201
-
202
- return extract_executable(reg)
203
-
204
- user_browser = get_user_preferred_browser()
205
- if user_browser:
206
- return extract_executable(user_browser)
207
-
208
- system_browser = get_system_default_browser()
209
- if system_browser:
210
- return extract_executable(system_browser)
File without changes
File without changes
File without changes
File without changes
File without changes