iotsploit-core 0.0.6__tar.gz → 0.0.7__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.
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/PKG-INFO +3 -2
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/pyproject.toml +1 -1
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/execution_backend.py +9 -1
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/tool_manager.py +132 -108
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/tool_service.py +264 -96
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/README.md +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/__init__.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/context.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/__init__.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/base_plugin.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/device_config.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/device_manager.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/device_registry.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/device_scanner.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/device_spec.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/device_store.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/execution_queue.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/exploit_manager.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/exploit_spec.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/stream_manager.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/tool_config.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/domain/__init__.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/domain/device.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/domain/execution_plan.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/domain/plugin.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/domain/stream.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/domain/target.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/platforms/__init__.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/platforms/consts.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/ports/__init__.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/ports/driver_state_repo.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/ports/plugin_repo.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/ports/stream_backend.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/ports/task_runner.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/ports/wifi_backend.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/py.typed +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/utils/__init__.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/utils/exceptions.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/utils/helpers.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/utils/iots_logger.py +0 -0
- {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/utils/result.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: iotsploit-core
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.7
|
|
4
4
|
Summary: IoTSploit core library (domain + ports + core) - IoT security testing framework
|
|
5
5
|
License: GPL-3.0-or-later
|
|
6
6
|
Keywords: iot,security,testing,pentest,vulnerability
|
|
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
20
|
Classifier: Topic :: Security
|
|
20
21
|
Classifier: Topic :: System :: Hardware
|
|
21
22
|
Requires-Dist: dataclasses-json (>=0.6.3,<0.7.0)
|
|
@@ -439,7 +439,15 @@ class ExecutionBackendManager:
|
|
|
439
439
|
def list_available_backends(self) -> List[str]:
|
|
440
440
|
"""List all available backends"""
|
|
441
441
|
return list(self.backends.keys())
|
|
442
|
-
|
|
442
|
+
|
|
443
|
+
def get_status(self) -> Dict[str, Any]:
|
|
444
|
+
"""Get status of all execution backends"""
|
|
445
|
+
return {
|
|
446
|
+
'default_backend': self.default_backend,
|
|
447
|
+
'available_backends': self.list_available_backends(),
|
|
448
|
+
'total_backends': len(self.backends),
|
|
449
|
+
}
|
|
450
|
+
|
|
443
451
|
def set_default_backend(self, name: str):
|
|
444
452
|
"""Set default execution backend"""
|
|
445
453
|
if name not in self.backends:
|
|
@@ -11,7 +11,7 @@ import logging
|
|
|
11
11
|
from abc import ABC, abstractmethod
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Dict, List, Optional, Union, Any, Tuple
|
|
14
|
-
from dataclasses import dataclass, field
|
|
14
|
+
from dataclasses import dataclass, field, asdict
|
|
15
15
|
from enum import Enum
|
|
16
16
|
import threading
|
|
17
17
|
import time
|
|
@@ -24,6 +24,20 @@ from .execution_queue import get_execution_queue, TaskPriority
|
|
|
24
24
|
|
|
25
25
|
logger = logging.getLogger(__name__)
|
|
26
26
|
|
|
27
|
+
# Detection results older than this (seconds) are re-probed; fresh ones are served
|
|
28
|
+
# from the runtime cache instead of re-running every tool's `--version`.
|
|
29
|
+
TOOL_CACHE_TTL_SECONDS = 3600
|
|
30
|
+
|
|
31
|
+
# Curated, version-controlled fields. These live in conf/tools.json and are never
|
|
32
|
+
# rewritten by runtime detection.
|
|
33
|
+
STATIC_TOOL_FIELDS = (
|
|
34
|
+
"name", "aliases", "dependencies", "platforms",
|
|
35
|
+
"min_version", "max_version", "metadata",
|
|
36
|
+
)
|
|
37
|
+
# Machine-specific detection results. These live in the gitignored runtime cache
|
|
38
|
+
# (.tools_cache.json) because they differ per host and change on every probe.
|
|
39
|
+
RUNTIME_TOOL_FIELDS = ("path", "version", "status", "last_checked")
|
|
40
|
+
|
|
27
41
|
class ToolStatus(Enum):
|
|
28
42
|
"""Tool availability status"""
|
|
29
43
|
AVAILABLE = "available"
|
|
@@ -77,6 +91,8 @@ class ToolRegistry:
|
|
|
77
91
|
def __init__(self, config_path: Optional[str] = None):
|
|
78
92
|
self.tools: Dict[str, ToolInfo] = {}
|
|
79
93
|
self.config_path = config_path or self._get_default_config_path()
|
|
94
|
+
# Runtime detection cache lives next to the config but is gitignored.
|
|
95
|
+
self.cache_path = str(Path(self.config_path).parent / ".tools_cache.json")
|
|
80
96
|
self._lock = threading.RLock()
|
|
81
97
|
self._load_config()
|
|
82
98
|
|
|
@@ -93,25 +109,30 @@ class ToolRegistry:
|
|
|
93
109
|
return str(config_dir / "tools.json")
|
|
94
110
|
|
|
95
111
|
def _load_config(self):
|
|
96
|
-
"""Load tool
|
|
112
|
+
"""Load curated tool definitions from the version-controlled config file."""
|
|
97
113
|
if os.path.exists(self.config_path):
|
|
98
114
|
try:
|
|
99
115
|
with open(self.config_path, 'r') as f:
|
|
100
116
|
config = json.load(f)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
known_fields = set(STATIC_TOOL_FIELDS) | set(RUNTIME_TOOL_FIELDS)
|
|
118
|
+
for tool_name, tool_data in config.get('tools', {}).items():
|
|
119
|
+
# Tolerate legacy files that still carry runtime fields, and drop
|
|
120
|
+
# any unknown keys so the ToolInfo constructor never fails.
|
|
121
|
+
data = {k: v for k, v in tool_data.items() if k in known_fields}
|
|
122
|
+
data.setdefault('name', tool_name)
|
|
123
|
+
if isinstance(data.get('status'), str):
|
|
124
|
+
try:
|
|
125
|
+
data['status'] = ToolStatus(data['status'])
|
|
126
|
+
except ValueError:
|
|
127
|
+
data['status'] = ToolStatus.MISSING
|
|
128
|
+
self.tools[tool_name] = ToolInfo(**data)
|
|
129
|
+
logger.info(f"Loaded {len(self.tools)} tool definitions from config")
|
|
111
130
|
except Exception as e:
|
|
112
131
|
logger.error(f"Failed to load tool config: {e}")
|
|
113
132
|
else:
|
|
114
133
|
self._create_default_config()
|
|
134
|
+
# Overlay machine-specific detection results from the runtime cache.
|
|
135
|
+
self._load_cache()
|
|
115
136
|
|
|
116
137
|
def _create_default_config(self):
|
|
117
138
|
"""Create default tool configuration"""
|
|
@@ -154,36 +175,80 @@ class ToolRegistry:
|
|
|
154
175
|
|
|
155
176
|
self.save_config()
|
|
156
177
|
|
|
178
|
+
def _load_cache(self):
|
|
179
|
+
"""Overlay machine-specific detection results from the runtime cache onto
|
|
180
|
+
the loaded definitions. A missing or unreadable cache is normal."""
|
|
181
|
+
if not os.path.exists(self.cache_path):
|
|
182
|
+
return
|
|
183
|
+
try:
|
|
184
|
+
with open(self.cache_path, 'r') as f:
|
|
185
|
+
cache = json.load(f)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.warning(f"Ignoring unreadable tool cache {self.cache_path}: {e}")
|
|
188
|
+
return
|
|
189
|
+
for name, runtime in cache.get('tools', {}).items():
|
|
190
|
+
tool = self.tools.get(name)
|
|
191
|
+
if not tool:
|
|
192
|
+
continue
|
|
193
|
+
if 'path' in runtime:
|
|
194
|
+
tool.path = runtime['path']
|
|
195
|
+
if 'version' in runtime:
|
|
196
|
+
tool.version = runtime['version']
|
|
197
|
+
if 'last_checked' in runtime:
|
|
198
|
+
tool.last_checked = runtime['last_checked']
|
|
199
|
+
if isinstance(runtime.get('status'), str):
|
|
200
|
+
try:
|
|
201
|
+
tool.status = ToolStatus(runtime['status'])
|
|
202
|
+
except ValueError:
|
|
203
|
+
tool.status = ToolStatus.MISSING
|
|
204
|
+
|
|
157
205
|
def save_config(self):
|
|
158
|
-
"""
|
|
206
|
+
"""Persist the curated tool definitions (static fields only).
|
|
207
|
+
|
|
208
|
+
Detection results are intentionally excluded — they belong in the runtime
|
|
209
|
+
cache (see save_cache) so this version-controlled file stays stable across
|
|
210
|
+
hosts and process restarts. Call this only when a definition changes."""
|
|
159
211
|
try:
|
|
160
212
|
config = {
|
|
213
|
+
'tools': {
|
|
214
|
+
name: {field: getattr(tool, field) for field in STATIC_TOOL_FIELDS}
|
|
215
|
+
for name, tool in self.tools.items()
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
with open(self.config_path, 'w') as f:
|
|
219
|
+
json.dump(config, f, indent=2)
|
|
220
|
+
logger.debug(f"Saved tool definitions to {self.config_path}")
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.error(f"Failed to save tool config: {e}")
|
|
223
|
+
import traceback
|
|
224
|
+
logger.debug(f"Save config traceback: {traceback.format_exc()}")
|
|
225
|
+
|
|
226
|
+
def save_cache(self):
|
|
227
|
+
"""Persist machine-specific detection results to the gitignored runtime cache.
|
|
228
|
+
|
|
229
|
+
path/version/status/last_checked differ per host and change on every probe,
|
|
230
|
+
so they must never land in the version-controlled tools.json."""
|
|
231
|
+
try:
|
|
232
|
+
cache = {
|
|
161
233
|
'tools': {
|
|
162
234
|
name: {
|
|
163
|
-
'name': tool.name,
|
|
164
235
|
'path': tool.path,
|
|
165
236
|
'version': tool.version,
|
|
166
237
|
'status': tool.status.value if hasattr(tool.status, 'value') else str(tool.status),
|
|
167
|
-
'aliases': tool.aliases,
|
|
168
|
-
'dependencies': tool.dependencies,
|
|
169
|
-
'platforms': tool.platforms,
|
|
170
|
-
'min_version': tool.min_version,
|
|
171
|
-
'max_version': tool.max_version,
|
|
172
238
|
'last_checked': tool.last_checked,
|
|
173
|
-
'metadata': tool.metadata
|
|
174
239
|
}
|
|
175
240
|
for name, tool in self.tools.items()
|
|
176
241
|
}
|
|
177
242
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
243
|
+
Path(self.cache_path).parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
# Guarded by self._lock against threads, but not against multiple
|
|
245
|
+
# processes sharing one cache file. TODO: add file locking if iotsploit
|
|
246
|
+
# is ever run as concurrent processes against the same cache.
|
|
247
|
+
with open(self.cache_path, 'w') as f:
|
|
248
|
+
json.dump(cache, f, indent=2)
|
|
249
|
+
logger.debug(f"Saved tool runtime cache to {self.cache_path}")
|
|
182
250
|
except Exception as e:
|
|
183
|
-
logger.error(f"Failed to save tool
|
|
184
|
-
# Add more detailed error information
|
|
185
|
-
import traceback
|
|
186
|
-
logger.debug(f"Save config traceback: {traceback.format_exc()}")
|
|
251
|
+
logger.error(f"Failed to save tool cache: {e}")
|
|
187
252
|
|
|
188
253
|
def register_tool(self, tool_info: ToolInfo, save_config: bool = True):
|
|
189
254
|
"""Register a new tool"""
|
|
@@ -477,8 +542,8 @@ class ToolManager:
|
|
|
477
542
|
validated_tool = self.validator.validate_tool(tool_info)
|
|
478
543
|
self.registry.tools[tool_name] = validated_tool
|
|
479
544
|
results[tool_name] = validated_tool.status
|
|
480
|
-
|
|
481
|
-
self.registry.
|
|
545
|
+
|
|
546
|
+
self.registry.save_cache()
|
|
482
547
|
return results
|
|
483
548
|
|
|
484
549
|
def get_tool_status(self, tool_name: str) -> Optional[ToolStatus]:
|
|
@@ -488,11 +553,11 @@ class ToolManager:
|
|
|
488
553
|
return None
|
|
489
554
|
|
|
490
555
|
# Refresh status if not checked recently
|
|
491
|
-
if not tool_info.last_checked or (time.time() - tool_info.last_checked) >
|
|
556
|
+
if not tool_info.last_checked or (time.time() - tool_info.last_checked) > TOOL_CACHE_TTL_SECONDS:
|
|
492
557
|
tool_info = self.validator.validate_tool(tool_info)
|
|
493
558
|
self.registry.tools[tool_info.name] = tool_info
|
|
494
|
-
self.registry.
|
|
495
|
-
|
|
559
|
+
self.registry.save_cache()
|
|
560
|
+
|
|
496
561
|
return tool_info.status
|
|
497
562
|
|
|
498
563
|
def execute(self, tool_name: str, args: List[str], **kwargs) -> ExecutionResult:
|
|
@@ -525,91 +590,50 @@ class ToolManager:
|
|
|
525
590
|
# Validate the tool
|
|
526
591
|
validated_tool = self.validator.validate_tool(tool_info)
|
|
527
592
|
self.registry.register_tool(validated_tool, save_config=save_config)
|
|
528
|
-
|
|
593
|
+
if save_config:
|
|
594
|
+
# Persisting a new tool also persists its freshly-probed runtime state.
|
|
595
|
+
self.registry.save_cache()
|
|
596
|
+
|
|
529
597
|
return validated_tool.status == ToolStatus.AVAILABLE
|
|
530
598
|
|
|
531
599
|
# ========== Methods from ToolCategoryManager ==========
|
|
532
600
|
|
|
533
601
|
def _initialize_tools(self):
|
|
534
|
-
"""
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
#
|
|
541
|
-
if not
|
|
542
|
-
|
|
543
|
-
tools_file = Path('conf') / 'tools.json'
|
|
544
|
-
if not tools_file.exists():
|
|
545
|
-
tools_file = self.config_manager.config_dir / "tools.json"
|
|
546
|
-
|
|
547
|
-
if tools_file.exists():
|
|
548
|
-
with open(tools_file, 'r', encoding='utf-8') as f:
|
|
549
|
-
tools_data = json.load(f)
|
|
550
|
-
|
|
551
|
-
tools_added = 0
|
|
552
|
-
# Support tools field as dict or list
|
|
553
|
-
tools_section = tools_data.get('tools', [])
|
|
554
|
-
if isinstance(tools_section, dict):
|
|
555
|
-
items = tools_section.items()
|
|
556
|
-
else:
|
|
557
|
-
items = [(td.get('name', 'unknown'), td) for td in tools_section if isinstance(td, dict)]
|
|
558
|
-
for tool_name, tool_data in items:
|
|
559
|
-
try:
|
|
560
|
-
# Build tool_data dict and ensure it contains name
|
|
561
|
-
data = tool_data.copy() if isinstance(tool_data, dict) else {}
|
|
562
|
-
data.setdefault('name', tool_name)
|
|
563
|
-
tool_config = ToolConfig.from_dict(data)
|
|
564
|
-
tool_config.category = "tools"
|
|
565
|
-
# Add tool to manager (don't save config for each tool)
|
|
566
|
-
success = self.add_tool(
|
|
567
|
-
name=tool_config.name,
|
|
568
|
-
aliases=tool_config.aliases,
|
|
569
|
-
min_version=tool_config.min_version,
|
|
570
|
-
platforms=tool_config.platforms,
|
|
571
|
-
path=tool_config.path,
|
|
572
|
-
save_config=False
|
|
573
|
-
)
|
|
574
|
-
if success:
|
|
575
|
-
tools_added += 1
|
|
576
|
-
logger.debug(f"Added tool: {tool_config.name}")
|
|
577
|
-
else:
|
|
578
|
-
logger.warning(f"Failed to add tool: {tool_config.name}")
|
|
579
|
-
except Exception as e:
|
|
580
|
-
logger.error(f"Error adding tool {tool_name}: {e}")
|
|
581
|
-
|
|
582
|
-
# Save config once at the end
|
|
583
|
-
if tools_added > 0:
|
|
584
|
-
self.registry.save_config()
|
|
585
|
-
logger.info(f"Successfully initialized {tools_added} tools")
|
|
586
|
-
else:
|
|
587
|
-
# Load tools from category
|
|
588
|
-
tools_added = 0
|
|
589
|
-
for tool_config in tool_configs:
|
|
602
|
+
"""Validate registered tools, honoring the runtime cache so tools probed
|
|
603
|
+
recently are not re-probed on every startup."""
|
|
604
|
+
logger.info("Initializing tools from configuration")
|
|
605
|
+
|
|
606
|
+
# The registry already loaded curated definitions (and any cached runtime
|
|
607
|
+
# state) from tools.json. If it came up empty, fall back to the category
|
|
608
|
+
# manager as the definition source.
|
|
609
|
+
if not self.registry.tools:
|
|
610
|
+
for tool_config in self.config_manager.get_tools_by_category("tools"):
|
|
590
611
|
try:
|
|
591
|
-
|
|
612
|
+
self.registry.tools[tool_config.name] = ToolInfo(
|
|
592
613
|
name=tool_config.name,
|
|
593
|
-
aliases=tool_config.aliases,
|
|
594
|
-
min_version=tool_config.min_version,
|
|
595
|
-
platforms=tool_config.platforms,
|
|
596
614
|
path=tool_config.path,
|
|
597
|
-
|
|
615
|
+
aliases=tool_config.aliases or [],
|
|
616
|
+
min_version=tool_config.min_version,
|
|
617
|
+
platforms=tool_config.platforms or ["linux", "darwin", "windows"],
|
|
598
618
|
)
|
|
599
|
-
|
|
600
|
-
if success:
|
|
601
|
-
tools_added += 1
|
|
602
|
-
logger.debug(f"Added tool: {tool_config.name}")
|
|
603
|
-
else:
|
|
604
|
-
logger.warning(f"Failed to add tool: {tool_config.name}")
|
|
605
|
-
|
|
606
619
|
except Exception as e:
|
|
607
620
|
logger.error(f"Error adding tool {tool_config.name}: {e}")
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
621
|
+
|
|
622
|
+
now = time.time()
|
|
623
|
+
revalidated = 0
|
|
624
|
+
for name, tool_info in list(self.registry.tools.items()):
|
|
625
|
+
# Skip tools whose cached detection result is still fresh.
|
|
626
|
+
if tool_info.last_checked and (now - tool_info.last_checked) < TOOL_CACHE_TTL_SECONDS:
|
|
627
|
+
continue
|
|
628
|
+
self.registry.tools[name] = self.validator.validate_tool(tool_info)
|
|
629
|
+
revalidated += 1
|
|
630
|
+
|
|
631
|
+
# Only touch the runtime cache when something was actually re-probed.
|
|
632
|
+
if revalidated:
|
|
633
|
+
self.registry.save_cache()
|
|
634
|
+
logger.info(
|
|
635
|
+
f"Initialized {len(self.registry.tools)} tools ({revalidated} re-probed)"
|
|
636
|
+
)
|
|
613
637
|
|
|
614
638
|
def get_category_info(self) -> CategoryInfo:
|
|
615
639
|
"""Get category information from JSON config"""
|
|
@@ -883,7 +907,7 @@ class ToolManager:
|
|
|
883
907
|
},
|
|
884
908
|
'recommendations': status.recommendations,
|
|
885
909
|
'backend_status': self.backend_manager.get_status(),
|
|
886
|
-
'queue_status': self.queue_manager.get_stats()
|
|
910
|
+
'queue_status': asdict(self.queue_manager.get_stats())
|
|
887
911
|
}
|
|
888
912
|
|
|
889
913
|
return report
|
|
@@ -594,37 +594,193 @@ class GreatFETProgrammer(BaseProgrammer):
|
|
|
594
594
|
return self.execute_tool('greatfet_firmware', args, timeout=300)
|
|
595
595
|
|
|
596
596
|
class FirmwareToolService(ToolService):
|
|
597
|
-
"""Tool service specialized for firmware operations
|
|
598
|
-
|
|
597
|
+
"""Tool service specialized for firmware operations.
|
|
598
|
+
|
|
599
|
+
Manifest resolution order (later entries override earlier ones):
|
|
600
|
+
1. Built-in manifest shipped inside the ``iotsploit_drivers`` package at
|
|
601
|
+
``resources/firmware_manifest.json``.
|
|
602
|
+
2. User override manifest at ``~/.iotsploit/firmware_manifest.json``
|
|
603
|
+
(also where runtime-added firmware entries are persisted).
|
|
604
|
+
|
|
605
|
+
Firmware entries may reference their binary in two ways:
|
|
606
|
+
* ``"resource": "<package>:<relpath>"`` — a logical reference resolved
|
|
607
|
+
via :func:`importlib.resources.as_file`. Preferred for bundled
|
|
608
|
+
defaults so wheels and editable installs both work.
|
|
609
|
+
* ``"path": "<filesystem path>"`` — a plain filesystem path. Intended
|
|
610
|
+
for user-added firmware.
|
|
611
|
+
|
|
612
|
+
Multi-file entries may use ``flash_options.files`` where each item has
|
|
613
|
+
either ``"resource"`` or ``"path"``.
|
|
614
|
+
|
|
615
|
+
Call :meth:`resolve_firmware` to obtain a context manager that yields
|
|
616
|
+
a dict with all ``resource`` entries materialized to real filesystem
|
|
617
|
+
paths — required for subprocess tools like ``esptool`` and
|
|
618
|
+
``openFPGALoader`` that need a concrete path on disk.
|
|
619
|
+
"""
|
|
620
|
+
|
|
621
|
+
#: Name of the built-in manifest resource inside iotsploit_drivers.
|
|
622
|
+
_BUILTIN_MANIFEST_PACKAGE = "iotsploit_drivers"
|
|
623
|
+
_BUILTIN_MANIFEST_RELPATH = "resources/firmware_manifest.json"
|
|
624
|
+
|
|
599
625
|
def __init__(self):
|
|
600
626
|
super().__init__()
|
|
601
|
-
|
|
602
|
-
# Initialize firmware registry
|
|
627
|
+
|
|
603
628
|
from pathlib import Path
|
|
604
|
-
|
|
605
|
-
#
|
|
606
|
-
self.
|
|
607
|
-
self.
|
|
608
|
-
self.
|
|
629
|
+
|
|
630
|
+
# User directory for overrides and runtime-added firmware.
|
|
631
|
+
self.user_dir = Path.home() / ".iotsploit"
|
|
632
|
+
self.user_dir.mkdir(exist_ok=True)
|
|
633
|
+
self.user_manifest_file = self.user_dir / "firmware_manifest.json"
|
|
634
|
+
|
|
609
635
|
self.manifests = self._load_manifests()
|
|
610
|
-
|
|
636
|
+
|
|
611
637
|
# Initialize programmers
|
|
612
638
|
self.esp32 = ESP32Programmer(self)
|
|
613
639
|
self.stm32 = STM32Programmer(self)
|
|
614
640
|
self.dfu = DFUProgrammer(self)
|
|
615
641
|
self.fpga = FPGAProgrammer(self)
|
|
616
642
|
self.greatfet = GreatFETProgrammer(self)
|
|
617
|
-
|
|
643
|
+
|
|
644
|
+
# ------------------------------------------------------------------
|
|
645
|
+
# Manifest loading
|
|
646
|
+
# ------------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
def _load_builtin_manifest(self) -> Dict:
|
|
649
|
+
"""Load the manifest shipped inside the iotsploit_drivers package."""
|
|
650
|
+
try:
|
|
651
|
+
from importlib.resources import files
|
|
652
|
+
except ImportError: # pragma: no cover - Python <3.9 not supported
|
|
653
|
+
return {}
|
|
654
|
+
|
|
655
|
+
try:
|
|
656
|
+
traversable = files(self._BUILTIN_MANIFEST_PACKAGE).joinpath(
|
|
657
|
+
self._BUILTIN_MANIFEST_RELPATH
|
|
658
|
+
)
|
|
659
|
+
data = json.loads(traversable.read_text(encoding="utf-8"))
|
|
660
|
+
except (ModuleNotFoundError, FileNotFoundError):
|
|
661
|
+
self.logger.warning(
|
|
662
|
+
"Built-in firmware manifest not found in %s:%s",
|
|
663
|
+
self._BUILTIN_MANIFEST_PACKAGE,
|
|
664
|
+
self._BUILTIN_MANIFEST_RELPATH,
|
|
665
|
+
)
|
|
666
|
+
return {}
|
|
667
|
+
except json.JSONDecodeError as exc:
|
|
668
|
+
self.logger.error("Built-in firmware manifest is invalid JSON: %s", exc)
|
|
669
|
+
return {}
|
|
670
|
+
|
|
671
|
+
# Drop schema metadata key if present.
|
|
672
|
+
data.pop("_schema", None)
|
|
673
|
+
return data
|
|
674
|
+
|
|
675
|
+
def _load_user_manifest(self) -> Dict:
|
|
676
|
+
"""Load the user override manifest from ~/.iotsploit."""
|
|
677
|
+
if not self.user_manifest_file.exists():
|
|
678
|
+
return {}
|
|
679
|
+
try:
|
|
680
|
+
with open(self.user_manifest_file, "r") as f:
|
|
681
|
+
data = json.load(f)
|
|
682
|
+
except json.JSONDecodeError as exc:
|
|
683
|
+
self.logger.error(
|
|
684
|
+
"User firmware manifest %s is invalid JSON: %s",
|
|
685
|
+
self.user_manifest_file,
|
|
686
|
+
exc,
|
|
687
|
+
)
|
|
688
|
+
return {}
|
|
689
|
+
data.pop("_schema", None)
|
|
690
|
+
return data
|
|
691
|
+
|
|
618
692
|
def _load_manifests(self) -> Dict:
|
|
619
|
-
"""
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
693
|
+
"""Merge built-in and user manifests. User entries take precedence."""
|
|
694
|
+
merged: Dict = {}
|
|
695
|
+
merged.update(self._load_builtin_manifest())
|
|
696
|
+
merged.update(self._load_user_manifest())
|
|
697
|
+
return merged
|
|
698
|
+
|
|
699
|
+
# ------------------------------------------------------------------
|
|
700
|
+
# Firmware resolution
|
|
701
|
+
# ------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
@staticmethod
|
|
704
|
+
def _parse_resource_ref(resource_ref: str):
|
|
705
|
+
"""Parse ``"<package>:<relpath>"`` into its components."""
|
|
706
|
+
if ":" not in resource_ref:
|
|
707
|
+
raise ValueError(
|
|
708
|
+
f"Invalid resource ref {resource_ref!r}; "
|
|
709
|
+
f"expected '<package>:<relpath>'"
|
|
710
|
+
)
|
|
711
|
+
package, relpath = resource_ref.split(":", 1)
|
|
712
|
+
package = package.strip()
|
|
713
|
+
relpath = relpath.strip().lstrip("/")
|
|
714
|
+
if not package or not relpath:
|
|
715
|
+
raise ValueError(f"Invalid resource ref {resource_ref!r}")
|
|
716
|
+
return package, relpath
|
|
717
|
+
|
|
718
|
+
@classmethod
|
|
719
|
+
def _resource_as_path(cls, resource_ref: str):
|
|
720
|
+
"""Return a context manager yielding a concrete Path for a resource ref.
|
|
721
|
+
|
|
722
|
+
Uses :func:`importlib.resources.as_file` so resources packed inside
|
|
723
|
+
zipped wheels are materialized to a temp file for the duration of the
|
|
724
|
+
``with`` block.
|
|
725
|
+
"""
|
|
726
|
+
from importlib.resources import files, as_file
|
|
727
|
+
|
|
728
|
+
package, relpath = cls._parse_resource_ref(resource_ref)
|
|
729
|
+
traversable = files(package).joinpath(relpath)
|
|
730
|
+
return as_file(traversable)
|
|
731
|
+
|
|
732
|
+
def resolve_firmware(self, name: str):
|
|
733
|
+
"""Context manager yielding a firmware_info dict with real paths.
|
|
734
|
+
|
|
735
|
+
The yielded dict is a shallow copy of the manifest entry where:
|
|
736
|
+
* ``resource`` has been replaced by ``path`` (a real filesystem str)
|
|
737
|
+
* ``flash_options.files[*].resource`` has been replaced by ``path``
|
|
738
|
+
|
|
739
|
+
All temporary files (if any) are kept alive for the duration of the
|
|
740
|
+
``with`` block. Raises :class:`KeyError` if ``name`` is not in the
|
|
741
|
+
manifest.
|
|
742
|
+
"""
|
|
743
|
+
from contextlib import contextmanager, ExitStack
|
|
744
|
+
from pathlib import Path
|
|
745
|
+
|
|
746
|
+
info = self.manifests.get(name)
|
|
747
|
+
if info is None:
|
|
748
|
+
raise KeyError(f"Firmware {name!r} not found in manifest")
|
|
749
|
+
|
|
750
|
+
@contextmanager
|
|
751
|
+
def _ctx():
|
|
752
|
+
with ExitStack() as stack:
|
|
753
|
+
resolved = dict(info)
|
|
754
|
+
|
|
755
|
+
# Top-level resource/path.
|
|
756
|
+
if "resource" in info:
|
|
757
|
+
p = stack.enter_context(self._resource_as_path(info["resource"]))
|
|
758
|
+
resolved["path"] = str(p)
|
|
759
|
+
resolved.pop("resource", None)
|
|
760
|
+
elif "path" in info:
|
|
761
|
+
resolved["path"] = str(info["path"])
|
|
762
|
+
|
|
763
|
+
# flash_options.files[*]
|
|
764
|
+
flash_options = dict(info.get("flash_options", {}))
|
|
765
|
+
if "files" in flash_options:
|
|
766
|
+
new_files = []
|
|
767
|
+
for entry in flash_options["files"]:
|
|
768
|
+
new_entry = dict(entry)
|
|
769
|
+
if "resource" in entry:
|
|
770
|
+
p = stack.enter_context(
|
|
771
|
+
self._resource_as_path(entry["resource"])
|
|
772
|
+
)
|
|
773
|
+
new_entry["path"] = str(p)
|
|
774
|
+
new_entry.pop("resource", None)
|
|
775
|
+
elif "path" in entry:
|
|
776
|
+
new_entry["path"] = str(entry["path"])
|
|
777
|
+
new_files.append(new_entry)
|
|
778
|
+
flash_options["files"] = new_files
|
|
779
|
+
|
|
780
|
+
resolved["flash_options"] = flash_options
|
|
781
|
+
yield resolved
|
|
782
|
+
|
|
783
|
+
return _ctx()
|
|
628
784
|
|
|
629
785
|
def add_firmware(self, name: str, path: str, device_type: str, version: str,
|
|
630
786
|
flash_options: Optional[Dict[str, Any]] = None) -> bool:
|
|
@@ -690,7 +846,9 @@ class FirmwareToolService(ToolService):
|
|
|
690
846
|
filename = url.split('/')[-1]
|
|
691
847
|
if not filename:
|
|
692
848
|
filename = "downloaded_firmware.bin"
|
|
693
|
-
|
|
849
|
+
downloads_dir = self.user_dir / "firmware"
|
|
850
|
+
downloads_dir.mkdir(parents=True, exist_ok=True)
|
|
851
|
+
output_path = str(downloads_dir / filename)
|
|
694
852
|
|
|
695
853
|
with open(output_path, 'wb') as f:
|
|
696
854
|
for chunk in response.iter_content(chunk_size=8192):
|
|
@@ -719,93 +877,103 @@ class FirmwareToolService(ToolService):
|
|
|
719
877
|
self.logger.error(f"Firmware not found: {name}")
|
|
720
878
|
return False
|
|
721
879
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
880
|
+
with self.resolve_firmware(name) as firmware_info:
|
|
881
|
+
device_type = firmware_info.get('device_type', '').lower()
|
|
882
|
+
firmware_path = firmware_info.get('path')
|
|
883
|
+
|
|
884
|
+
# Merge options from manifest and provided options
|
|
885
|
+
flash_options = dict(firmware_info.get('flash_options', {}))
|
|
886
|
+
if options:
|
|
887
|
+
flash_options.update(options)
|
|
888
|
+
|
|
889
|
+
# Route to appropriate programmer based on device type
|
|
890
|
+
if device_type.startswith('esp32'):
|
|
891
|
+
if 'files' in flash_options:
|
|
892
|
+
# Multi-file ESP32 flash
|
|
893
|
+
result = self.esp32.flash_multi(
|
|
894
|
+
port=flash_options.get('port', '/dev/ttyUSB0'),
|
|
895
|
+
files=flash_options['files'],
|
|
896
|
+
chip=flash_options.get('chip', 'esp32s3'),
|
|
897
|
+
baud=flash_options.get('baud', '460800')
|
|
898
|
+
)
|
|
899
|
+
else:
|
|
900
|
+
# Single file ESP32 flash
|
|
901
|
+
result = self.esp32.flash_single(
|
|
902
|
+
port=flash_options.get('port', '/dev/ttyUSB0'),
|
|
903
|
+
firmware_path=firmware_path,
|
|
904
|
+
address=flash_options.get('address', '0x10000'),
|
|
905
|
+
chip=flash_options.get('chip', 'esp32'),
|
|
906
|
+
baud=flash_options.get('baud', '460800')
|
|
907
|
+
)
|
|
908
|
+
return result.success
|
|
909
|
+
|
|
910
|
+
elif device_type.startswith('stm32'):
|
|
911
|
+
result = self.stm32.flash_firmware(
|
|
912
|
+
firmware_path=firmware_path,
|
|
913
|
+
interface=flash_options.get('interface', 'stlink'),
|
|
914
|
+
target=flash_options.get('target', 'stm32f4x')
|
|
740
915
|
)
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
916
|
+
return result.success
|
|
917
|
+
|
|
918
|
+
elif device_type == 'dfu':
|
|
919
|
+
result = self.dfu.flash_firmware(
|
|
745
920
|
firmware_path=firmware_path,
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
921
|
+
vid=flash_options.get('vid'),
|
|
922
|
+
pid=flash_options.get('pid'),
|
|
923
|
+
alt=flash_options.get('alt')
|
|
749
924
|
)
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
board=flash_options.get('board')
|
|
775
|
-
cable=flash_options.get('cable')
|
|
925
|
+
return result.success
|
|
926
|
+
|
|
927
|
+
elif device_type.startswith('fpga'):
|
|
928
|
+
target = flash_options.get('target', 'sram').lower()
|
|
929
|
+
if target == 'sram':
|
|
930
|
+
result = self.fpga.load_sram(
|
|
931
|
+
bitstream_path=firmware_path,
|
|
932
|
+
board=flash_options.get('board'),
|
|
933
|
+
cable=flash_options.get('cable')
|
|
934
|
+
)
|
|
935
|
+
else:
|
|
936
|
+
result = self.fpga.flash_bitstream(
|
|
937
|
+
bitstream_path=firmware_path,
|
|
938
|
+
board=flash_options.get('board'),
|
|
939
|
+
cable=flash_options.get('cable'),
|
|
940
|
+
external_flash=flash_options.get('external_flash', False)
|
|
941
|
+
)
|
|
942
|
+
return result.success
|
|
943
|
+
|
|
944
|
+
elif device_type.startswith('greatfet'):
|
|
945
|
+
result = self.greatfet.flash_firmware(
|
|
946
|
+
firmware_path=firmware_path,
|
|
947
|
+
target=flash_options.get('target', 'spi'),
|
|
948
|
+
serial=flash_options.get('serial'),
|
|
949
|
+
board=flash_options.get('board')
|
|
776
950
|
)
|
|
951
|
+
return result.success
|
|
952
|
+
|
|
777
953
|
else:
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
board=flash_options.get('board'),
|
|
781
|
-
cable=flash_options.get('cable'),
|
|
782
|
-
external_flash=flash_options.get('external_flash', False)
|
|
783
|
-
)
|
|
784
|
-
return result.success
|
|
785
|
-
|
|
786
|
-
elif device_type.startswith('greatfet'):
|
|
787
|
-
result = self.greatfet.flash_firmware(
|
|
788
|
-
firmware_path=firmware_path,
|
|
789
|
-
target=flash_options.get('target', 'spi'),
|
|
790
|
-
serial=flash_options.get('serial'),
|
|
791
|
-
board=flash_options.get('board')
|
|
792
|
-
)
|
|
793
|
-
return result.success
|
|
794
|
-
|
|
795
|
-
else:
|
|
796
|
-
self.logger.error(f"Unsupported device type: {device_type}")
|
|
797
|
-
return False
|
|
954
|
+
self.logger.error(f"Unsupported device type: {device_type}")
|
|
955
|
+
return False
|
|
798
956
|
|
|
799
957
|
except Exception as e:
|
|
800
958
|
self.logger.error(f"Error flashing firmware: {str(e)}")
|
|
801
959
|
return False
|
|
802
960
|
|
|
803
961
|
def _save_manifests(self):
|
|
804
|
-
"""
|
|
962
|
+
"""Persist user-facing manifest entries to ``~/.iotsploit/firmware_manifest.json``.
|
|
963
|
+
|
|
964
|
+
Only entries that differ from the built-in manifest are written so
|
|
965
|
+
that the user override file stays minimal and keeps working even when
|
|
966
|
+
the bundled defaults change in a future iotsploit-drivers release.
|
|
967
|
+
"""
|
|
805
968
|
try:
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
969
|
+
builtin = self._load_builtin_manifest()
|
|
970
|
+
user_entries = {
|
|
971
|
+
name: info
|
|
972
|
+
for name, info in self.manifests.items()
|
|
973
|
+
if builtin.get(name) != info
|
|
974
|
+
}
|
|
975
|
+
with open(self.user_manifest_file, 'w') as f:
|
|
976
|
+
json.dump(user_entries, f, indent=2)
|
|
809
977
|
except Exception as e:
|
|
810
978
|
self.logger.error(f"Error saving firmware manifest: {str(e)}")
|
|
811
979
|
|
|
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
|