iotsploit-cli 0.0.8__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.
Files changed (24) hide show
  1. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/PKG-INFO +56 -13
  2. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/README.md +52 -12
  3. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/pyproject.toml +3 -1
  4. iotsploit_cli-0.0.9/src/iotsploit_cli/can_live.py +383 -0
  5. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/src/iotsploit_cli/command_palette.py +95 -11
  6. iotsploit_cli-0.0.9/src/iotsploit_cli/command_registry.py +139 -0
  7. iotsploit_cli-0.0.9/src/iotsploit_cli/commands/can_commands.py +112 -0
  8. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/device_commands.py +39 -14
  9. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/django_commands.py +161 -76
  10. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/firmware_commands.py +5 -3
  11. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/plugin_commands.py +41 -39
  12. iotsploit_cli-0.0.9/src/iotsploit_cli/commands/priv_commands.py +91 -0
  13. iotsploit_cli-0.0.9/src/iotsploit_cli/commands/resource_commands.py +278 -0
  14. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/system_commands.py +3 -1
  15. iotsploit_cli-0.0.9/src/iotsploit_cli/commands/target_commands.py +614 -0
  16. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/src/iotsploit_cli/console.py +119 -113
  17. iotsploit_cli-0.0.9/src/iotsploit_cli/interaction_console.py +158 -0
  18. iotsploit_cli-0.0.9/src/iotsploit_cli/plugin_log_console.py +72 -0
  19. iotsploit_cli-0.0.8/src/iotsploit_cli/commands/target_commands.py +0 -245
  20. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/src/iotsploit_cli/__init__.py +0 -0
  21. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/__init__.py +0 -0
  22. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/base_commands.py +0 -0
  23. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/linux_commands.py +0 -0
  24. {iotsploit_cli-0.0.8 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/network_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iotsploit-cli
3
- Version: 0.0.8
3
+ Version: 0.0.9
4
4
  Summary: IoTSploit CLI shell (console + command modules) - IoT security testing interactive interface
5
5
  License: GPL-3.0-or-later
6
6
  Keywords: iot,security,testing,pentest,cli,shell
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
20
  Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Programming Language :: Python :: 3.15
21
22
  Classifier: Topic :: Security
22
23
  Classifier: Topic :: System :: Hardware
23
24
  Requires-Dist: cmd2 (>=2.4,<3.0)
@@ -26,7 +27,9 @@ Requires-Dist: iotsploit-django
26
27
  Requires-Dist: iotsploit-drivers
27
28
  Requires-Dist: iotsploit-exploits
28
29
  Requires-Dist: iotsploit-mcp
30
+ Requires-Dist: iotsploit-priv
29
31
  Requires-Dist: prompt-toolkit (>=3.0.48,<4.0.0)
32
+ Requires-Dist: websockets (>=12.0,<13.0)
30
33
  Project-URL: Documentation, https://www.iotsploit.org/
31
34
  Project-URL: Homepage, https://www.iotsploit.org/
32
35
  Project-URL: Repository, https://github.com/TKXB/iotsploit
@@ -61,26 +64,64 @@ Or with the Django server started immediately:
61
64
  iotsploit --runserver
62
65
  ```
63
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
+
64
85
  ### Custom plugins
65
86
 
66
87
  `IOTSPLOIT_EXPLOIT_PLUGINS_DIR` can be used for user custom exploit plugins.
67
88
  `IOTSPLOIT_DEVICE_PLUGINS_DIR` can be used for user custom device plugins.
68
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
+
69
111
  ## Command Palette
70
112
 
71
- The IoTSploit shell includes a live command palette for all commands. When
72
- you type any character at the top-level interactive prompt, a menu appears
73
- immediately showing every eligible command matching that prefix with a short
74
- description beside each entry.
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.
75
117
 
76
118
  ### How it works
77
119
 
78
120
  1. Start typing any character at the empty prompt.
79
- 2. The menu lists all visible commands matching the typed prefix (e.g. `e`
80
- shows `edit`, `exploit`, `exit`, `execute_plugin`; `h` shows `help`,
81
- `history`).
82
- 3. Additional characters filter the list in real time (e.g. `ex` narrows to
83
- `exit`, `execute_plugin`, `exploit`).
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`).
84
125
  4. Navigate the list, insert a selection, or dismiss the menu.
85
126
 
86
127
  ### Keyboard controls
@@ -94,7 +135,9 @@ description beside each entry.
94
135
  | Enter | Accept the selected command and submit through cmd2 dispatch |
95
136
  | Escape | Close the menu and retain the current input text |
96
137
  | Backspace to empty | Close the menu |
97
- | Space after a command | Close the menu and allow argument entry |
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 |
98
141
  | Ctrl+C | Cancel the current input (normal shell behavior) |
99
142
  | Ctrl+D on empty line | Exit the shell (normal EOF behavior) |
100
143
 
@@ -103,8 +146,8 @@ description beside each entry.
103
146
  - The palette is **TTY-only**. Non-interactive use (piped input, startup
104
147
  scripts, non-TTY stdin) bypasses the palette entirely and uses the normal
105
148
  cmd2 input path.
106
- - The command list is derived dynamically from the runtime command registry,
107
- so newly loaded command modules appear without editing the palette.
149
+ - The command list comes from the same canonical registry as help and the
150
+ argparse command definitions, so names and explanations stay aligned.
108
151
  - Tab completion for arguments (after a space) still uses cmd2's existing
109
152
  completion engine, including argument-specific completers and argparse
110
153
  completers.
@@ -27,26 +27,64 @@ Or with the Django server started immediately:
27
27
  iotsploit --runserver
28
28
  ```
29
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
+
30
48
  ### Custom plugins
31
49
 
32
50
  `IOTSPLOIT_EXPLOIT_PLUGINS_DIR` can be used for user custom exploit plugins.
33
51
  `IOTSPLOIT_DEVICE_PLUGINS_DIR` can be used for user custom device plugins.
34
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
+
35
74
  ## Command Palette
36
75
 
37
- The IoTSploit shell includes a live command palette for all commands. When
38
- you type any character at the top-level interactive prompt, a menu appears
39
- immediately showing every eligible command matching that prefix with a short
40
- description beside each entry.
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.
41
80
 
42
81
  ### How it works
43
82
 
44
83
  1. Start typing any character at the empty prompt.
45
- 2. The menu lists all visible commands matching the typed prefix (e.g. `e`
46
- shows `edit`, `exploit`, `exit`, `execute_plugin`; `h` shows `help`,
47
- `history`).
48
- 3. Additional characters filter the list in real time (e.g. `ex` narrows to
49
- `exit`, `execute_plugin`, `exploit`).
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`).
50
88
  4. Navigate the list, insert a selection, or dismiss the menu.
51
89
 
52
90
  ### Keyboard controls
@@ -60,7 +98,9 @@ description beside each entry.
60
98
  | Enter | Accept the selected command and submit through cmd2 dispatch |
61
99
  | Escape | Close the menu and retain the current input text |
62
100
  | Backspace to empty | Close the menu |
63
- | Space after a command | Close the menu and allow argument entry |
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 |
64
104
  | Ctrl+C | Cancel the current input (normal shell behavior) |
65
105
  | Ctrl+D on empty line | Exit the shell (normal EOF behavior) |
66
106
 
@@ -69,8 +109,8 @@ description beside each entry.
69
109
  - The palette is **TTY-only**. Non-interactive use (piped input, startup
70
110
  scripts, non-TTY stdin) bypasses the palette entirely and uses the normal
71
111
  cmd2 input path.
72
- - The command list is derived dynamically from the runtime command registry,
73
- so newly loaded command modules appear without editing the palette.
112
+ - The command list comes from the same canonical registry as help and the
113
+ argparse command definitions, so names and explanations stay aligned.
74
114
  - Tab completion for arguments (after a space) still uses cmd2's existing
75
115
  completion engine, including argument-specific completers and argparse
76
116
  completers.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "iotsploit-cli"
3
- version = "0.0.8"
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
+ )