iotsploit-cli 0.0.7__tar.gz → 0.0.9__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_cli-0.0.9/PKG-INFO +161 -0
- iotsploit_cli-0.0.9/README.md +123 -0
- {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/pyproject.toml +4 -2
- iotsploit_cli-0.0.9/src/iotsploit_cli/can_live.py +383 -0
- iotsploit_cli-0.0.9/src/iotsploit_cli/command_palette.py +433 -0
- iotsploit_cli-0.0.9/src/iotsploit_cli/command_registry.py +139 -0
- iotsploit_cli-0.0.9/src/iotsploit_cli/commands/can_commands.py +112 -0
- {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/device_commands.py +41 -16
- iotsploit_cli-0.0.9/src/iotsploit_cli/commands/django_commands.py +383 -0
- {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/firmware_commands.py +5 -4
- {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/linux_commands.py +0 -1
- {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/network_commands.py +9 -5
- {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/plugin_commands.py +108 -86
- iotsploit_cli-0.0.9/src/iotsploit_cli/commands/priv_commands.py +91 -0
- iotsploit_cli-0.0.9/src/iotsploit_cli/commands/resource_commands.py +278 -0
- {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/system_commands.py +47 -1
- iotsploit_cli-0.0.9/src/iotsploit_cli/commands/target_commands.py +614 -0
- {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/console.py +247 -114
- iotsploit_cli-0.0.9/src/iotsploit_cli/interaction_console.py +158 -0
- iotsploit_cli-0.0.9/src/iotsploit_cli/plugin_log_console.py +72 -0
- iotsploit_cli-0.0.7/PKG-INFO +0 -68
- iotsploit_cli-0.0.7/README.md +0 -33
- iotsploit_cli-0.0.7/src/iotsploit_cli/commands/django_commands.py +0 -254
- iotsploit_cli-0.0.7/src/iotsploit_cli/commands/target_commands.py +0 -246
- {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/__init__.py +0 -0
- {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/__init__.py +0 -0
- {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/base_commands.py +0 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iotsploit-cli
|
|
3
|
+
Version: 0.0.9
|
|
4
|
+
Summary: IoTSploit CLI shell (console + command modules) - IoT security testing interactive interface
|
|
5
|
+
License: GPL-3.0-or-later
|
|
6
|
+
Keywords: iot,security,testing,pentest,cli,shell
|
|
7
|
+
Author: IoTSploit Team
|
|
8
|
+
Author-email: support@iotsploit.org
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Information Technology
|
|
14
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Topic :: System :: Hardware
|
|
24
|
+
Requires-Dist: cmd2 (>=2.4,<3.0)
|
|
25
|
+
Requires-Dist: iotsploit-core
|
|
26
|
+
Requires-Dist: iotsploit-django
|
|
27
|
+
Requires-Dist: iotsploit-drivers
|
|
28
|
+
Requires-Dist: iotsploit-exploits
|
|
29
|
+
Requires-Dist: iotsploit-mcp
|
|
30
|
+
Requires-Dist: iotsploit-priv
|
|
31
|
+
Requires-Dist: prompt-toolkit (>=3.0.48,<4.0.0)
|
|
32
|
+
Requires-Dist: websockets (>=12.0,<13.0)
|
|
33
|
+
Project-URL: Documentation, https://www.iotsploit.org/
|
|
34
|
+
Project-URL: Homepage, https://www.iotsploit.org/
|
|
35
|
+
Project-URL: Repository, https://github.com/TKXB/iotsploit
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# iotsploit-cli
|
|
39
|
+
|
|
40
|
+
IoTSploit interactive CLI shell for IoT security testing.
|
|
41
|
+
|
|
42
|
+
## Overview
|
|
43
|
+
|
|
44
|
+
This package provides the `iotsploit` command-line shell built on top of `cmd2`.
|
|
45
|
+
It bundles the core console loop (`console.py`) and all command modules
|
|
46
|
+
(`commands/`) that implement device management, plugin execution, target
|
|
47
|
+
management, network operations, and more.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install iotsploit-cli
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
iotsploit
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or with the Django server started immediately:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
iotsploit --runserver
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Choose the backend and MCP listening addresses when the defaults are not suitable:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
iotsploit --runserver \
|
|
71
|
+
--host 0.0.0.0 --api-port 8080 --ws-port 8081 \
|
|
72
|
+
--mcp-host 127.0.0.1 --mcp-port 9901
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The same options are available inside the shell:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
<IoX_SHELL> service start --host 0.0.0.0 --api-port 8080 --ws-port 8081
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`--host` controls the API and WebSocket listeners. MCP remains on loopback by
|
|
82
|
+
default because it does not authenticate incoming requests; expose it only on
|
|
83
|
+
a protected network. All ports must be distinct.
|
|
84
|
+
|
|
85
|
+
### Custom plugins
|
|
86
|
+
|
|
87
|
+
`IOTSPLOIT_EXPLOIT_PLUGINS_DIR` can be used for user custom exploit plugins.
|
|
88
|
+
`IOTSPLOIT_DEVICE_PLUGINS_DIR` can be used for user custom device plugins.
|
|
89
|
+
|
|
90
|
+
## Command standard
|
|
91
|
+
|
|
92
|
+
Application commands use a predictable `resource action` grammar:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
device list
|
|
96
|
+
driver status
|
|
97
|
+
firmware flash <firmware> <device>
|
|
98
|
+
plugin run <plugin>
|
|
99
|
+
target export [file]
|
|
100
|
+
service status
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The top-level resources are `host`, `device`, `driver`, `firmware`, `plugin`,
|
|
104
|
+
`target`, `service`, `wifi`, and `config`. Run `help` for the concise public
|
|
105
|
+
surface, `help <resource>` for its actions, or `help --all` for advanced cmd2
|
|
106
|
+
commands and the legacy-name migration table.
|
|
107
|
+
|
|
108
|
+
Previous command names and abbreviations remain executable during the
|
|
109
|
+
migration. They print a deprecation warning with the canonical replacement.
|
|
110
|
+
|
|
111
|
+
## Command Palette
|
|
112
|
+
|
|
113
|
+
The IoTSploit shell includes a live command palette for the canonical command
|
|
114
|
+
surface. When you type at the top-level prompt, a menu shows matching resources
|
|
115
|
+
with a short explanation. After a resource and a space, it shows that
|
|
116
|
+
resource's actions.
|
|
117
|
+
|
|
118
|
+
### How it works
|
|
119
|
+
|
|
120
|
+
1. Start typing any character at the empty prompt.
|
|
121
|
+
2. The menu lists canonical resources and essential shell commands matching
|
|
122
|
+
the typed prefix (for example, `d` shows `device` and `driver`).
|
|
123
|
+
3. Type a resource and a space to see its actions (for example, `plugin `
|
|
124
|
+
shows `list`, `run`, `run-all`, and `refresh`).
|
|
125
|
+
4. Navigate the list, insert a selection, or dismiss the menu.
|
|
126
|
+
|
|
127
|
+
### Keyboard controls
|
|
128
|
+
|
|
129
|
+
| Key | Behavior |
|
|
130
|
+
|-----|----------|
|
|
131
|
+
| Any first-token character | Open the palette menu |
|
|
132
|
+
| Additional characters | Filter the list case-insensitively |
|
|
133
|
+
| Up / Down | Move selection without changing the buffer |
|
|
134
|
+
| Tab | Insert the selected command name (does not submit) |
|
|
135
|
+
| Enter | Accept the selected command and submit through cmd2 dispatch |
|
|
136
|
+
| Escape | Close the menu and retain the current input text |
|
|
137
|
+
| Backspace to empty | Close the menu |
|
|
138
|
+
| Space after a resource | Show the resource's actions |
|
|
139
|
+
| Space after `service start` | Show the available endpoint options |
|
|
140
|
+
| Space after another action | Close the menu and allow argument entry |
|
|
141
|
+
| Ctrl+C | Cancel the current input (normal shell behavior) |
|
|
142
|
+
| Ctrl+D on empty line | Exit the shell (normal EOF behavior) |
|
|
143
|
+
|
|
144
|
+
### Behaviour notes
|
|
145
|
+
|
|
146
|
+
- The palette is **TTY-only**. Non-interactive use (piped input, startup
|
|
147
|
+
scripts, non-TTY stdin) bypasses the palette entirely and uses the normal
|
|
148
|
+
cmd2 input path.
|
|
149
|
+
- The command list comes from the same canonical registry as help and the
|
|
150
|
+
argparse command definitions, so names and explanations stay aligned.
|
|
151
|
+
- Tab completion for arguments (after a space) still uses cmd2's existing
|
|
152
|
+
completion engine, including argument-specific completers and argparse
|
|
153
|
+
completers.
|
|
154
|
+
- Selecting a command from the palette does **not** execute it; it inserts the
|
|
155
|
+
command name so you can type arguments before pressing Enter.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
GPL-3.0-or-later. See [LICENSE](../LICENSE) for details.
|
|
160
|
+
For commercial use, contact wang3919379@gmail.com.
|
|
161
|
+
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# iotsploit-cli
|
|
2
|
+
|
|
3
|
+
IoTSploit interactive CLI shell for IoT security testing.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This package provides the `iotsploit` command-line shell built on top of `cmd2`.
|
|
8
|
+
It bundles the core console loop (`console.py`) and all command modules
|
|
9
|
+
(`commands/`) that implement device management, plugin execution, target
|
|
10
|
+
management, network operations, and more.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install iotsploit-cli
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
iotsploit
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or with the Django server started immediately:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
iotsploit --runserver
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Choose the backend and MCP listening addresses when the defaults are not suitable:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
iotsploit --runserver \
|
|
34
|
+
--host 0.0.0.0 --api-port 8080 --ws-port 8081 \
|
|
35
|
+
--mcp-host 127.0.0.1 --mcp-port 9901
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The same options are available inside the shell:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
<IoX_SHELL> service start --host 0.0.0.0 --api-port 8080 --ws-port 8081
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`--host` controls the API and WebSocket listeners. MCP remains on loopback by
|
|
45
|
+
default because it does not authenticate incoming requests; expose it only on
|
|
46
|
+
a protected network. All ports must be distinct.
|
|
47
|
+
|
|
48
|
+
### Custom plugins
|
|
49
|
+
|
|
50
|
+
`IOTSPLOIT_EXPLOIT_PLUGINS_DIR` can be used for user custom exploit plugins.
|
|
51
|
+
`IOTSPLOIT_DEVICE_PLUGINS_DIR` can be used for user custom device plugins.
|
|
52
|
+
|
|
53
|
+
## Command standard
|
|
54
|
+
|
|
55
|
+
Application commands use a predictable `resource action` grammar:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
device list
|
|
59
|
+
driver status
|
|
60
|
+
firmware flash <firmware> <device>
|
|
61
|
+
plugin run <plugin>
|
|
62
|
+
target export [file]
|
|
63
|
+
service status
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The top-level resources are `host`, `device`, `driver`, `firmware`, `plugin`,
|
|
67
|
+
`target`, `service`, `wifi`, and `config`. Run `help` for the concise public
|
|
68
|
+
surface, `help <resource>` for its actions, or `help --all` for advanced cmd2
|
|
69
|
+
commands and the legacy-name migration table.
|
|
70
|
+
|
|
71
|
+
Previous command names and abbreviations remain executable during the
|
|
72
|
+
migration. They print a deprecation warning with the canonical replacement.
|
|
73
|
+
|
|
74
|
+
## Command Palette
|
|
75
|
+
|
|
76
|
+
The IoTSploit shell includes a live command palette for the canonical command
|
|
77
|
+
surface. When you type at the top-level prompt, a menu shows matching resources
|
|
78
|
+
with a short explanation. After a resource and a space, it shows that
|
|
79
|
+
resource's actions.
|
|
80
|
+
|
|
81
|
+
### How it works
|
|
82
|
+
|
|
83
|
+
1. Start typing any character at the empty prompt.
|
|
84
|
+
2. The menu lists canonical resources and essential shell commands matching
|
|
85
|
+
the typed prefix (for example, `d` shows `device` and `driver`).
|
|
86
|
+
3. Type a resource and a space to see its actions (for example, `plugin `
|
|
87
|
+
shows `list`, `run`, `run-all`, and `refresh`).
|
|
88
|
+
4. Navigate the list, insert a selection, or dismiss the menu.
|
|
89
|
+
|
|
90
|
+
### Keyboard controls
|
|
91
|
+
|
|
92
|
+
| Key | Behavior |
|
|
93
|
+
|-----|----------|
|
|
94
|
+
| Any first-token character | Open the palette menu |
|
|
95
|
+
| Additional characters | Filter the list case-insensitively |
|
|
96
|
+
| Up / Down | Move selection without changing the buffer |
|
|
97
|
+
| Tab | Insert the selected command name (does not submit) |
|
|
98
|
+
| Enter | Accept the selected command and submit through cmd2 dispatch |
|
|
99
|
+
| Escape | Close the menu and retain the current input text |
|
|
100
|
+
| Backspace to empty | Close the menu |
|
|
101
|
+
| Space after a resource | Show the resource's actions |
|
|
102
|
+
| Space after `service start` | Show the available endpoint options |
|
|
103
|
+
| Space after another action | Close the menu and allow argument entry |
|
|
104
|
+
| Ctrl+C | Cancel the current input (normal shell behavior) |
|
|
105
|
+
| Ctrl+D on empty line | Exit the shell (normal EOF behavior) |
|
|
106
|
+
|
|
107
|
+
### Behaviour notes
|
|
108
|
+
|
|
109
|
+
- The palette is **TTY-only**. Non-interactive use (piped input, startup
|
|
110
|
+
scripts, non-TTY stdin) bypasses the palette entirely and uses the normal
|
|
111
|
+
cmd2 input path.
|
|
112
|
+
- The command list comes from the same canonical registry as help and the
|
|
113
|
+
argparse command definitions, so names and explanations stay aligned.
|
|
114
|
+
- Tab completion for arguments (after a space) still uses cmd2's existing
|
|
115
|
+
completion engine, including argument-specific completers and argparse
|
|
116
|
+
completers.
|
|
117
|
+
- Selecting a command from the palette does **not** execute it; it inserts the
|
|
118
|
+
command name so you can type arguments before pressing Enter.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
GPL-3.0-or-later. See [LICENSE](../LICENSE) for details.
|
|
123
|
+
For commercial use, contact wang3919379@gmail.com.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "iotsploit-cli"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.9"
|
|
4
4
|
description = "IoTSploit CLI shell (console + command modules) - IoT security testing interactive interface"
|
|
5
5
|
authors = ["IoTSploit Team <support@iotsploit.org>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -34,8 +34,10 @@ iotsploit-django = "*"
|
|
|
34
34
|
iotsploit-drivers = "*"
|
|
35
35
|
iotsploit-exploits = "*"
|
|
36
36
|
iotsploit-mcp = "*"
|
|
37
|
+
iotsploit-priv = "*"
|
|
37
38
|
cmd2 = "^2.4"
|
|
38
|
-
|
|
39
|
+
prompt-toolkit = "^3.0.48"
|
|
40
|
+
websockets = "^12.0"
|
|
39
41
|
|
|
40
42
|
[tool.poetry.group.dev.dependencies]
|
|
41
43
|
pytest = "^7.4.0"
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Live decoded CAN sessions for the interactive CLI.
|
|
2
|
+
|
|
3
|
+
The capture plugin already publishes changed-row snapshots over the same
|
|
4
|
+
WebSocket used by Flutter. This module is the terminal adapter for that
|
|
5
|
+
contract: it starts a durable execution, folds snapshots into a stable table,
|
|
6
|
+
and cancels the execution when the operator presses Ctrl-C.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Callable, Mapping, TextIO
|
|
18
|
+
from urllib.error import HTTPError, URLError
|
|
19
|
+
from urllib.request import Request, urlopen
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
DEFAULT_API_BASE = "http://127.0.0.1:8888"
|
|
23
|
+
DEFAULT_WS_BASE = "ws://127.0.0.1:9999"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CanLiveError(RuntimeError):
|
|
27
|
+
"""A live session could not start or finish cleanly."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CanLiveRun:
|
|
32
|
+
target_id: str
|
|
33
|
+
bus_id: str
|
|
34
|
+
mode: str
|
|
35
|
+
max_frames: int
|
|
36
|
+
channel: str = ""
|
|
37
|
+
duration_s: int = 0
|
|
38
|
+
snapshot_interval_ms: int = 200
|
|
39
|
+
decode: bool = True
|
|
40
|
+
fd: bool = True
|
|
41
|
+
#: Set for ``replay``, where the traffic comes from a recorded log instead
|
|
42
|
+
#: of a socket. The path is read on the host running the backend, which is
|
|
43
|
+
#: not necessarily the host running this shell.
|
|
44
|
+
path: str = ""
|
|
45
|
+
log_channel: int | str | None = None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def source_label(self) -> str:
|
|
49
|
+
"""How the header names where the frames are coming from."""
|
|
50
|
+
if self.mode == "replay":
|
|
51
|
+
name = os.path.basename(self.path) or self.path
|
|
52
|
+
return f"Log {name}" + (
|
|
53
|
+
f" ch{self.log_channel}" if self.log_channel is not None else ""
|
|
54
|
+
)
|
|
55
|
+
return f"Channel {self.channel}"
|
|
56
|
+
|
|
57
|
+
def plugin_payload(self) -> dict[str, Any]:
|
|
58
|
+
if self.mode == "replay":
|
|
59
|
+
transport: dict[str, Any] = {"interface": "file", "path": self.path}
|
|
60
|
+
if self.log_channel is not None:
|
|
61
|
+
transport["log_channel"] = self.log_channel
|
|
62
|
+
request: dict[str, Any] = {
|
|
63
|
+
"schema_version": 1,
|
|
64
|
+
"bus_id": self.bus_id,
|
|
65
|
+
"transport": transport,
|
|
66
|
+
"mode": self.mode,
|
|
67
|
+
"max_frames": self.max_frames,
|
|
68
|
+
"snapshot_interval_ms": self.snapshot_interval_ms,
|
|
69
|
+
"decode": self.decode,
|
|
70
|
+
}
|
|
71
|
+
else:
|
|
72
|
+
request = {
|
|
73
|
+
"schema_version": 1,
|
|
74
|
+
"bus_id": self.bus_id,
|
|
75
|
+
"transport": {
|
|
76
|
+
"interface": "socketcan",
|
|
77
|
+
"channel": self.channel,
|
|
78
|
+
"fd": self.fd,
|
|
79
|
+
},
|
|
80
|
+
"mode": self.mode,
|
|
81
|
+
"duration_s": self.duration_s,
|
|
82
|
+
"max_frames": self.max_frames,
|
|
83
|
+
"snapshot_interval_ms": self.snapshot_interval_ms,
|
|
84
|
+
"decode": self.decode,
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
"plugin_name": "CAN Live Capture",
|
|
88
|
+
"target_id": self.target_id,
|
|
89
|
+
"parameters": {"bus_id": self.bus_id, "request": request},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class CanSnapshotView:
|
|
95
|
+
"""Fold changed-row snapshots into the whole table visible to an operator."""
|
|
96
|
+
|
|
97
|
+
rows: dict[tuple[int, bool], dict[str, Any]] = field(default_factory=dict)
|
|
98
|
+
totals: dict[str, int] = field(default_factory=dict)
|
|
99
|
+
bus_health: dict[str, int] = field(default_factory=dict)
|
|
100
|
+
unknown_overflowed: bool = False
|
|
101
|
+
final: bool = False
|
|
102
|
+
|
|
103
|
+
def merge(self, message: Mapping[str, Any]) -> bool:
|
|
104
|
+
envelope = message.get("data") if isinstance(message.get("data"), Mapping) else message
|
|
105
|
+
if not isinstance(envelope, Mapping) or not isinstance(envelope.get("rows"), list):
|
|
106
|
+
return False
|
|
107
|
+
for raw_row in envelope["rows"]:
|
|
108
|
+
if not isinstance(raw_row, Mapping):
|
|
109
|
+
continue
|
|
110
|
+
row = dict(raw_row)
|
|
111
|
+
try:
|
|
112
|
+
key = (int(row["frame_id"]), bool(row.get("is_extended", False)))
|
|
113
|
+
except (KeyError, TypeError, ValueError):
|
|
114
|
+
continue
|
|
115
|
+
self.rows[key] = row
|
|
116
|
+
if isinstance(envelope.get("totals"), Mapping):
|
|
117
|
+
self.totals = {str(key): int(value) for key, value in envelope["totals"].items()}
|
|
118
|
+
if isinstance(envelope.get("bus_health"), Mapping):
|
|
119
|
+
self.bus_health = {
|
|
120
|
+
str(key): int(value) for key, value in envelope["bus_health"].items()
|
|
121
|
+
}
|
|
122
|
+
self.unknown_overflowed = bool(envelope.get("unknown_overflowed", False))
|
|
123
|
+
self.final = bool(envelope.get("final", False))
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
def lines(self, run: CanLiveRun, *, width: int, height: int) -> list[str]:
|
|
127
|
+
status = "complete" if self.final else "capturing"
|
|
128
|
+
totals = self.totals
|
|
129
|
+
lines = [
|
|
130
|
+
f"CAN {run.mode} · {status}",
|
|
131
|
+
f"Target {run.target_id} · Bus {run.bus_id} · {run.source_label}",
|
|
132
|
+
(
|
|
133
|
+
f"Frames {totals.get('frames', 0)} · IDs {totals.get('identities', 0)} · "
|
|
134
|
+
f"Undefined {totals.get('undefined', 0)} · "
|
|
135
|
+
f"Undecodable {totals.get('undecodable', 0)} · "
|
|
136
|
+
f"Errors {totals.get('error_frames', 0)}"
|
|
137
|
+
),
|
|
138
|
+
]
|
|
139
|
+
if self.bus_health:
|
|
140
|
+
health = " · ".join(f"{key} {value}" for key, value in sorted(self.bus_health.items()))
|
|
141
|
+
lines.append(f"Bus health: {health}")
|
|
142
|
+
if self.unknown_overflowed:
|
|
143
|
+
lines.append("Unknown identity limit reached; additional unknown IDs are not retained.")
|
|
144
|
+
lines.extend(["", "ID Count Period Name / last decoded", "─" * min(width, 100)])
|
|
145
|
+
|
|
146
|
+
available_rows = max(height - len(lines) - 2, 1)
|
|
147
|
+
for row in sorted(self.rows.values(), key=lambda item: (item.get("frame_id", 0), item.get("is_extended", False)))[
|
|
148
|
+
:available_rows
|
|
149
|
+
]:
|
|
150
|
+
identity = str(row.get("frame_id_hex") or f"0x{int(row.get('frame_id', 0)):X}")
|
|
151
|
+
if row.get("is_extended"):
|
|
152
|
+
identity += "x"
|
|
153
|
+
period = row.get("period_ms")
|
|
154
|
+
period_text = "—" if period is None else f"{period:g}ms"
|
|
155
|
+
detail = _row_detail(row)
|
|
156
|
+
prefix = f"{identity:<11} {int(row.get('count', 0)):>6} {period_text:<8} "
|
|
157
|
+
lines.append((prefix + detail)[:width])
|
|
158
|
+
if len(self.rows) > available_rows:
|
|
159
|
+
lines.append(f"… {len(self.rows) - available_rows} more identities")
|
|
160
|
+
lines.append("Ctrl-C to stop")
|
|
161
|
+
return lines
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _row_detail(row: Mapping[str, Any]) -> str:
|
|
165
|
+
name = str(row.get("name") or "undefined")
|
|
166
|
+
if row.get("decode_error_reason"):
|
|
167
|
+
return f"{name} · decode failed: {row['decode_error_reason']}"
|
|
168
|
+
signals = row.get("last_signals")
|
|
169
|
+
if isinstance(signals, Mapping) and signals:
|
|
170
|
+
pairs = [f"{key}={value}" for key, value in list(signals.items())[:3]]
|
|
171
|
+
return f"{name} · " + " · ".join(pairs)
|
|
172
|
+
payload = row.get("last_data_hex")
|
|
173
|
+
return f"{name} · {payload}" if payload else name
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class TerminalCanRenderer:
|
|
177
|
+
"""Render a rolling table without erasing the surrounding shell history."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, output: TextIO | None = None, *, is_tty: bool | None = None):
|
|
180
|
+
self.output = output or sys.stdout
|
|
181
|
+
self.is_tty = self.output.isatty() if is_tty is None else is_tty
|
|
182
|
+
|
|
183
|
+
def __enter__(self):
|
|
184
|
+
if self.is_tty:
|
|
185
|
+
self.output.write("\x1b[?1049h\x1b[?25l")
|
|
186
|
+
self.output.flush()
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
def __exit__(self, *exc_info):
|
|
190
|
+
if self.is_tty:
|
|
191
|
+
self.output.write("\x1b[?25h\x1b[?1049l")
|
|
192
|
+
self.output.flush()
|
|
193
|
+
|
|
194
|
+
def show(self, view: CanSnapshotView, run: CanLiveRun) -> None:
|
|
195
|
+
size = shutil.get_terminal_size((120, 30))
|
|
196
|
+
lines = view.lines(run, width=size.columns, height=size.lines)
|
|
197
|
+
if self.is_tty:
|
|
198
|
+
self.output.write("\x1b[H\x1b[2J" + "\n".join(lines) + "\n")
|
|
199
|
+
else:
|
|
200
|
+
totals = view.totals
|
|
201
|
+
self.output.write(
|
|
202
|
+
f"{run.mode}: {totals.get('frames', 0)} frames, "
|
|
203
|
+
f"{totals.get('identities', 0)} identities"
|
|
204
|
+
f"{' (final)' if view.final else ''}\n"
|
|
205
|
+
)
|
|
206
|
+
self.output.flush()
|
|
207
|
+
|
|
208
|
+
def finish(self, view: CanSnapshotView, run: CanLiveRun, status: str) -> None:
|
|
209
|
+
totals = view.totals
|
|
210
|
+
self.output.write(
|
|
211
|
+
f"CAN {run.mode} {status}: {totals.get('frames', 0)} frames across "
|
|
212
|
+
f"{totals.get('identities', 0)} identities; "
|
|
213
|
+
f"{totals.get('undefined', 0)} undefined, "
|
|
214
|
+
f"{totals.get('undecodable', 0)} undecodable, "
|
|
215
|
+
f"{totals.get('error_frames', 0)} error frames.\n"
|
|
216
|
+
)
|
|
217
|
+
self.output.flush()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class DjangoExecutionApi:
|
|
221
|
+
def __init__(self, base_url: str = DEFAULT_API_BASE):
|
|
222
|
+
self.base_url = base_url.rstrip("/")
|
|
223
|
+
|
|
224
|
+
def start(self, run: CanLiveRun) -> str:
|
|
225
|
+
response = self._request("POST", "/api/execute_plugin/", run.plugin_payload())
|
|
226
|
+
execution_id = response.get("execution_id")
|
|
227
|
+
if not execution_id:
|
|
228
|
+
raise CanLiveError(str(response.get("message") or "backend returned no execution id"))
|
|
229
|
+
return str(execution_id)
|
|
230
|
+
|
|
231
|
+
def state(self, execution_id: str) -> dict[str, Any]:
|
|
232
|
+
return self._request("GET", f"/api/plugin-executions/{execution_id}/")
|
|
233
|
+
|
|
234
|
+
def cancel(self, execution_id: str) -> None:
|
|
235
|
+
self._request(
|
|
236
|
+
"POST",
|
|
237
|
+
f"/api/plugin-executions/{execution_id}/cancel/",
|
|
238
|
+
{"reason": "CAN live CLI stopped by operator"},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def _request(self, method: str, path: str, payload: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
242
|
+
body = json.dumps(payload).encode() if payload is not None else None
|
|
243
|
+
request = Request(
|
|
244
|
+
self.base_url + path,
|
|
245
|
+
data=body,
|
|
246
|
+
method=method,
|
|
247
|
+
headers={"Content-Type": "application/json"},
|
|
248
|
+
)
|
|
249
|
+
try:
|
|
250
|
+
with urlopen(request, timeout=10) as response: # noqa: S310 - operator-configured local service
|
|
251
|
+
return json.loads(response.read().decode())
|
|
252
|
+
except HTTPError as error:
|
|
253
|
+
detail = error.read().decode(errors="replace")
|
|
254
|
+
raise CanLiveError(f"backend HTTP {error.code}: {detail}") from error
|
|
255
|
+
except (URLError, TimeoutError, json.JSONDecodeError) as error:
|
|
256
|
+
raise CanLiveError(f"cannot reach IoTSploit backend at {self.base_url}: {error}") from error
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class WebSocketSnapshotStream:
|
|
260
|
+
def __init__(self, url: str):
|
|
261
|
+
try:
|
|
262
|
+
from websockets.sync.client import connect
|
|
263
|
+
|
|
264
|
+
self.connection = connect(url, open_timeout=5, close_timeout=1)
|
|
265
|
+
except Exception as error: # noqa: BLE001 - dependency and transport share one operator message
|
|
266
|
+
raise CanLiveError(f"cannot connect to CAN snapshot stream {url}: {error}") from error
|
|
267
|
+
|
|
268
|
+
def receive(self, timeout: float) -> Mapping[str, Any]:
|
|
269
|
+
try:
|
|
270
|
+
raw = self.connection.recv(timeout=timeout)
|
|
271
|
+
except TimeoutError:
|
|
272
|
+
raise
|
|
273
|
+
except Exception as error: # noqa: BLE001 - normalized for the session loop
|
|
274
|
+
raise EOFError(str(error)) from error
|
|
275
|
+
try:
|
|
276
|
+
message = json.loads(raw)
|
|
277
|
+
except (TypeError, json.JSONDecodeError) as error:
|
|
278
|
+
raise CanLiveError(f"CAN snapshot was not valid JSON: {error}") from error
|
|
279
|
+
if not isinstance(message, Mapping):
|
|
280
|
+
raise CanLiveError("CAN snapshot was not a JSON object")
|
|
281
|
+
return message
|
|
282
|
+
|
|
283
|
+
def close(self) -> None:
|
|
284
|
+
self.connection.close()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class CanLiveSession:
|
|
288
|
+
"""Coordinate one durable execution with its separate snapshot stream."""
|
|
289
|
+
|
|
290
|
+
TERMINAL = {"completed", "failed", "cancelled", "expired"}
|
|
291
|
+
|
|
292
|
+
def __init__(
|
|
293
|
+
self,
|
|
294
|
+
*,
|
|
295
|
+
api: DjangoExecutionApi | Any | None = None,
|
|
296
|
+
stream_factory: Callable[[str], Any] | None = None,
|
|
297
|
+
renderer: TerminalCanRenderer | Any | None = None,
|
|
298
|
+
ws_base_url: str = DEFAULT_WS_BASE,
|
|
299
|
+
):
|
|
300
|
+
self.api = api or DjangoExecutionApi(os.getenv("IOTSPLOIT_DJANGO_API_BASE_URL", DEFAULT_API_BASE))
|
|
301
|
+
self.stream_factory = stream_factory or WebSocketSnapshotStream
|
|
302
|
+
self.renderer = renderer or TerminalCanRenderer()
|
|
303
|
+
self.ws_base_url = ws_base_url.rstrip("/")
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def from_environment(cls, *, output: TextIO | None = None) -> "CanLiveSession":
|
|
307
|
+
return cls(
|
|
308
|
+
renderer=TerminalCanRenderer(output),
|
|
309
|
+
ws_base_url=os.getenv("IOTSPLOIT_DJANGO_WS_BASE_URL", DEFAULT_WS_BASE),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def run(self, run: CanLiveRun) -> dict[str, Any]:
|
|
313
|
+
stream_url = f"{self.ws_base_url}/ws/device/stream/can_capture_{run.bus_id}/"
|
|
314
|
+
stream = self.stream_factory(stream_url)
|
|
315
|
+
execution_id: str | None = None
|
|
316
|
+
view = CanSnapshotView()
|
|
317
|
+
status = "running"
|
|
318
|
+
try:
|
|
319
|
+
execution_id = self.api.start(run)
|
|
320
|
+
with self.renderer:
|
|
321
|
+
while not view.final:
|
|
322
|
+
try:
|
|
323
|
+
message = stream.receive(timeout=1.0)
|
|
324
|
+
except TimeoutError:
|
|
325
|
+
state = self.api.state(execution_id)
|
|
326
|
+
status = str(state.get("status") or status)
|
|
327
|
+
if status in self.TERMINAL:
|
|
328
|
+
break
|
|
329
|
+
continue
|
|
330
|
+
except EOFError:
|
|
331
|
+
state = self.api.state(execution_id)
|
|
332
|
+
status = str(state.get("status") or status)
|
|
333
|
+
if status not in self.TERMINAL:
|
|
334
|
+
raise CanLiveError("CAN snapshot stream closed before the execution finished")
|
|
335
|
+
break
|
|
336
|
+
if view.merge(message):
|
|
337
|
+
self.renderer.show(view, run)
|
|
338
|
+
state = self.api.state(execution_id)
|
|
339
|
+
status = str(state.get("status") or status)
|
|
340
|
+
_merge_result_if_snapshot_was_missed(view, state)
|
|
341
|
+
except KeyboardInterrupt:
|
|
342
|
+
status = "cancelled"
|
|
343
|
+
if execution_id is not None:
|
|
344
|
+
self.api.cancel(execution_id)
|
|
345
|
+
_receive_final_snapshot(stream, view, self.renderer, run)
|
|
346
|
+
finally:
|
|
347
|
+
stream.close()
|
|
348
|
+
|
|
349
|
+
self.renderer.finish(view, run, status)
|
|
350
|
+
if status == "failed":
|
|
351
|
+
error = state.get("error") if "state" in locals() else None
|
|
352
|
+
raise CanLiveError(f"CAN {run.mode} failed: {error}")
|
|
353
|
+
return {"execution_id": execution_id, "status": status, "view": view}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _receive_final_snapshot(stream, view, renderer, run) -> None:
|
|
357
|
+
deadline = time.monotonic() + 3.0
|
|
358
|
+
while not view.final and time.monotonic() < deadline:
|
|
359
|
+
try:
|
|
360
|
+
message = stream.receive(timeout=min(0.5, deadline - time.monotonic()))
|
|
361
|
+
except (TimeoutError, EOFError):
|
|
362
|
+
continue
|
|
363
|
+
if view.merge(message):
|
|
364
|
+
renderer.show(view, run)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _merge_result_if_snapshot_was_missed(view: CanSnapshotView, state: Mapping[str, Any]) -> None:
|
|
368
|
+
"""A dropped final snapshot must not turn a successful run into zero rows."""
|
|
369
|
+
if view.final:
|
|
370
|
+
return
|
|
371
|
+
result = state.get("result")
|
|
372
|
+
data = result.get("data") if isinstance(result, Mapping) else None
|
|
373
|
+
if not isinstance(data, Mapping) or not isinstance(data.get("frames"), list):
|
|
374
|
+
return
|
|
375
|
+
view.merge(
|
|
376
|
+
{
|
|
377
|
+
"rows": data["frames"],
|
|
378
|
+
"totals": data.get("totals", {}),
|
|
379
|
+
"bus_health": data.get("bus_health", {}),
|
|
380
|
+
"unknown_overflowed": data.get("unknown_overflowed", False),
|
|
381
|
+
"final": True,
|
|
382
|
+
}
|
|
383
|
+
)
|