lanscape 2.2.1__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.
- {lanscape-2.2.1/lanscape.egg-info → lanscape-2.3.0a1}/PKG-INFO +3 -1
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/net_tools.py +14 -3
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/service_scan.py +38 -3
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/blueprints/api/port.py +14 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/css/style.css +2 -2
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/scan-config.js +17 -6
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/config.html +5 -3
- {lanscape-2.2.1 → lanscape-2.3.0a1/lanscape.egg-info}/PKG-INFO +3 -1
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape.egg-info/SOURCES.txt +1 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape.egg-info/requires.txt +2 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/pyproject.toml +4 -2
- {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_api.py +29 -0
- lanscape-2.3.0a1/tests/test_port_scan_linux_fd_exhaustion.py +297 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/LICENSE +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/MANIFEST.in +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/README.md +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/__init__.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/__main__.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/__init__.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/app_scope.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/decorators.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/device_alive.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/errors.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/ip_parser.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/logger.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/mac_lookup.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/port_manager.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/runtime_args.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/scan_config.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/subnet_scan.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/version_manager.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/mac_addresses/convert_csv.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/mac_addresses/mac_db.json +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/ports/convert_csv.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/ports/full.json +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/ports/large.json +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/ports/medium.json +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/ports/small.json +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/ports/test_port_list_scan.json +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/services/definitions.jsonc +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/__init__.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/app.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/blueprints/__init__.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/blueprints/api/__init__.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/blueprints/api/scan.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/blueprints/api/tools.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/blueprints/web/__init__.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/blueprints/web/routes.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/main.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/shutdown_handler.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/android-chrome-192x192.png +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/android-chrome-512x512.png +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/apple-touch-icon.png +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/favicon-16x16.png +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/favicon-32x32.png +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/favicon.ico +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/site.webmanifest +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/core.js +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/layout-sizing.js +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/main.js +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/on-tab-close.js +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/quietReload.js +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/shutdown-server.js +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/subnet-info.js +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/subnet-selector.js +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/lanscape.webmanifest +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/base.html +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/core/head.html +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/core/scripts.html +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/error.html +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/info.html +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/main.html +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/device-detail.html +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/export.html +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/ip-table-row.html +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/ip-table.html +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/overview.html +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/scan-error.html +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan.html +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/shutdown.html +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape.egg-info/dependency_links.txt +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape.egg-info/entry_points.txt +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape.egg-info/top_level.txt +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/setup.cfg +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_decorators.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_env.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_globals.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_library.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_logging.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_port_scan.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_service_scan.py +0 -0
- {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lanscape
|
|
3
|
-
Version: 2.
|
|
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
|
|
@@ -31,6 +31,8 @@ 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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
187
|
-
|
|
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
|
"""
|
|
@@ -564,8 +564,8 @@ input[type="range"] {
|
|
|
564
564
|
/* Service Strategy Select Styles */
|
|
565
565
|
.service-strategy-wrapper {
|
|
566
566
|
position: relative;
|
|
567
|
-
display:
|
|
568
|
-
width:
|
|
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
|
-
|
|
143
|
-
|
|
142
|
+
const customSelectDropdown = $('#port_list');
|
|
143
|
+
|
|
144
|
+
const renderOptions = (items) => {
|
|
144
145
|
customSelectDropdown.empty();
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -31,6 +31,8 @@ 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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lanscape"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.3.0a1"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name="Michael Dennis", email="michael@dipduo.com" },
|
|
6
6
|
]
|
|
@@ -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
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|