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.
Files changed (27) hide show
  1. iotsploit_cli-0.0.9/PKG-INFO +161 -0
  2. iotsploit_cli-0.0.9/README.md +123 -0
  3. {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/pyproject.toml +4 -2
  4. iotsploit_cli-0.0.9/src/iotsploit_cli/can_live.py +383 -0
  5. iotsploit_cli-0.0.9/src/iotsploit_cli/command_palette.py +433 -0
  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.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/device_commands.py +41 -16
  9. iotsploit_cli-0.0.9/src/iotsploit_cli/commands/django_commands.py +383 -0
  10. {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/firmware_commands.py +5 -4
  11. {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/linux_commands.py +0 -1
  12. {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/network_commands.py +9 -5
  13. {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/plugin_commands.py +108 -86
  14. iotsploit_cli-0.0.9/src/iotsploit_cli/commands/priv_commands.py +91 -0
  15. iotsploit_cli-0.0.9/src/iotsploit_cli/commands/resource_commands.py +278 -0
  16. {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/system_commands.py +47 -1
  17. iotsploit_cli-0.0.9/src/iotsploit_cli/commands/target_commands.py +614 -0
  18. {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/console.py +247 -114
  19. iotsploit_cli-0.0.9/src/iotsploit_cli/interaction_console.py +158 -0
  20. iotsploit_cli-0.0.9/src/iotsploit_cli/plugin_log_console.py +72 -0
  21. iotsploit_cli-0.0.7/PKG-INFO +0 -68
  22. iotsploit_cli-0.0.7/README.md +0 -33
  23. iotsploit_cli-0.0.7/src/iotsploit_cli/commands/django_commands.py +0 -254
  24. iotsploit_cli-0.0.7/src/iotsploit_cli/commands/target_commands.py +0 -246
  25. {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/__init__.py +0 -0
  26. {iotsploit_cli-0.0.7 → iotsploit_cli-0.0.9}/src/iotsploit_cli/commands/__init__.py +0 -0
  27. {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.7"
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
- pwntools = "^4.12"
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
+ )