Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit ed98974

Browse files
committed
Fix lint issues and add doc file to the ssh-mitm driver
Signed-off-by: Bella Khizgiyaev <bkhizgiy@redhat.com>
1 parent 5a8b654 commit ed98974

6 files changed

Lines changed: 272 additions & 210 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# SSH MITM Driver
2+
3+
`jumpstarter-driver-ssh-mitm` provides secure SSH proxy functionality where private keys
4+
are stored on the exporter and never transmitted to clients.
5+
6+
## Installation
7+
8+
```{code-block} console
9+
:substitutions:
10+
$ pip3 install --extra-index-url {{index_url}} jumpstarter-driver-ssh-mitm
11+
```
12+
13+
## Configuration
14+
15+
Example configuration with inline key:
16+
17+
```yaml
18+
export:
19+
ssh_mitm:
20+
type: jumpstarter_driver_ssh_mitm.driver.SSHMITM
21+
config:
22+
default_username: "root"
23+
ssh_identity: |
24+
-----BEGIN OPENSSH PRIVATE KEY-----
25+
...
26+
-----END OPENSSH PRIVATE KEY-----
27+
children:
28+
tcp:
29+
type: jumpstarter_driver_network.driver.TcpNetwork
30+
config:
31+
host: "192.168.1.100"
32+
port: 22
33+
```
34+
35+
Example configuration with key file:
36+
37+
```yaml
38+
export:
39+
ssh_mitm:
40+
type: jumpstarter_driver_ssh_mitm.driver.SSHMITM
41+
config:
42+
default_username: "root"
43+
ssh_identity_file: "/path/to/private/key"
44+
children:
45+
tcp:
46+
type: jumpstarter_driver_network.driver.TcpNetwork
47+
config:
48+
host: "192.168.1.100"
49+
port: 22
50+
```
51+
52+
### Config parameters
53+
54+
| Parameter | Description | Type | Required | Default |
55+
| ----------------- | -------------------------------------------------------- | ---- | -------- | ------- |
56+
| default_username | Default SSH username | str | no | "" |
57+
| ssh_identity | SSH private key content (inline) | str | no* | None |
58+
| ssh_identity_file | Path to SSH private key file | str | no* | None |
59+
60+
\* Either `ssh_identity` or `ssh_identity_file` must be provided.
61+
62+
### Required children
63+
64+
- `tcp`: A `TcpNetwork` driver providing target host and port
65+
66+
## Usage
67+
68+
```bash
69+
# Execute a command
70+
j ssh_mitm whoami
71+
72+
# Interactive shell (native SSH via port forwarding)
73+
j ssh_mitm shell
74+
75+
# Interactive shell (gRPC REPL, no local SSH required)
76+
j ssh_mitm shell --repl
77+
78+
# Port forward for ssh/scp/rsync
79+
j ssh_mitm forward -p 2222
80+
# Then: ssh -p 2222 localhost
81+
```
82+
83+
## API Reference
84+
85+
```{eval-rst}
86+
.. autoclass:: jumpstarter_driver_ssh_mitm.driver.SSHMITM()
87+
```
88+
89+
```{eval-rst}
90+
.. autoclass:: jumpstarter_driver_ssh_mitm.client.SSHMITMClient()
91+
:members: execute, run
92+
```

packages/jumpstarter-driver-ssh-mitm/jumpstarter_driver_ssh_mitm/client.py

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
import shlex
1717
import shutil
1818
import subprocess
19-
import time
2019
import textwrap
20+
import time
2121
import uuid
2222
from dataclasses import dataclass
2323
from difflib import get_close_matches
@@ -60,6 +60,7 @@ def resolve_command(self, ctx, args):
6060
@dataclass
6161
class SSHMITMCommandRunResult:
6262
"""Result of executing a command via SSH MITM."""
63+
6364
return_code: int
6465
stdout: str
6566
stderr: str
@@ -69,15 +70,15 @@ class SSHMITMCommandRunResult:
6970
class SSHMITMClient(DriverClient):
7071
"""
7172
Client for SSH MITM proxy driver.
72-
73+
7374
Provides secure SSH access where the private key never leaves the exporter.
7475
Commands are executed via gRPC - the driver runs SSH on behalf of the client.
7576
"""
7677

77-
def cli(self):
78+
def cli(self): # noqa: C901
7879
"""Create CLI command for 'j ssh_mitm'."""
7980
client = self
80-
81+
8182
@click.group(
8283
"ssh",
8384
cls=DefaultCommandGroup,
@@ -119,7 +120,7 @@ def run(ctx, args):
119120

120121
if result.return_code != 0:
121122
ctx.exit(result.return_code)
122-
123+
123124
@ssh_cmd.command("shell")
124125
@click.option(
125126
"--repl",
@@ -131,7 +132,7 @@ def run(ctx, args):
131132
def shell(ctx, repl, ssh_args):
132133
"""
133134
Launch an SSH session through the MITM proxy.
134-
135+
135136
By default, spawns the system 'ssh' binary via port forwarding.
136137
Use --repl for the lightweight gRPC REPL shell.
137138
"""
@@ -141,7 +142,7 @@ def shell(ctx, repl, ssh_args):
141142
exit_code = client._launch_native_ssh(ssh_args)
142143
if exit_code != 0:
143144
ctx.exit(exit_code)
144-
145+
145146
@ssh_cmd.command("forward")
146147
@click.option(
147148
"--host",
@@ -162,15 +163,15 @@ def shell(ctx, repl, ssh_args):
162163
def forward(local_host, local_port):
163164
"""
164165
Expose the MITM proxy as a local TCP port for native SSH/scp/rsync.
165-
166+
166167
Example:
167168
j ssh_mitm forward -p 2222
168169
ssh -p 2222 localhost
169170
"""
170171
client._start_forward(local_host, local_port)
171-
172+
172173
return ssh_cmd
173-
174+
174175
def _ensure_ssh_binary(self) -> str:
175176
ssh_path = shutil.which("ssh")
176177
if not ssh_path:
@@ -198,59 +199,59 @@ def _launch_native_ssh(self, ssh_args: tuple[str, ...]) -> int:
198199
self.logger.debug("Launching native SSH: %s", shlex.join(ssh_command))
199200
return subprocess.call(ssh_command)
200201

201-
def _run_shell(self):
202+
def _run_shell(self): # noqa: C901
202203
"""Run interactive shell via gRPC commands."""
203204
username = self.call("get_default_username") or "user"
204205
hostname = "dut"
205-
206+
206207
try:
207208
result = self.execute(["hostname", "-s"])
208209
if result.return_code == 0 and result.stdout.strip():
209210
hostname = result.stdout.strip()
210211
except Exception as e:
211212
self.logger.debug("Failed to get hostname: %s", e)
212-
213+
213214
click.echo(f"Connected to {hostname} via SSH MITM proxy")
214215
click.echo("Type 'exit' or Ctrl+D to exit")
215216
click.echo()
216-
217+
217218
cwd = "~"
218-
219+
219220
while True:
220221
try:
221222
prompt = click.style(f"{username}@{hostname}", fg="green", bold=True)
222223
prompt += click.style(":", fg="white")
223224
prompt += click.style(cwd, fg="blue", bold=True)
224225
prompt += click.style("$ ", fg="white")
225-
226+
226227
cmd = input(prompt)
227-
228+
228229
if not cmd.strip():
229230
continue
230-
231+
231232
if cmd.strip() == "exit":
232233
click.echo("Connection closed.")
233234
break
234-
235+
235236
if cmd.strip().startswith("cd "):
236237
new_dir = cmd.strip()[3:].strip()
237238
result = self.execute(
238239
[
239240
"bash",
240241
"-c",
241-
f'cd {shlex.quote(cwd)} 2>/dev/null; cd {shlex.quote(new_dir)} && pwd',
242+
f"cd {shlex.quote(cwd)} 2>/dev/null; cd {shlex.quote(new_dir)} && pwd",
242243
]
243244
)
244245
if result.return_code == 0 and result.stdout.strip():
245246
cwd = result.stdout.strip()
246247
else:
247248
click.echo(f"cd: {new_dir}: No such file or directory", err=True)
248249
continue
249-
250+
250251
if cmd.strip() == "cd":
251252
cwd = "~"
252253
continue
253-
254+
254255
# Execute command in current directory using newline-delimited heredoc to avoid interpolation
255256
token = f"JSSHMITM_{uuid.uuid4().hex}"
256257
script = (
@@ -265,20 +266,20 @@ def _run_shell(self):
265266
+ "\n"
266267
)
267268
result = self.execute(["bash", "-lc", script])
268-
269+
269270
if result.stdout:
270271
click.echo(result.stdout, nl=False)
271272
if result.stderr:
272273
click.echo(result.stderr, nl=False, err=True)
273-
274+
274275
except EOFError:
275276
click.echo()
276277
click.echo("Connection closed.")
277278
break
278279
except KeyboardInterrupt:
279280
click.echo("^C")
280281
continue
281-
282+
282283
def _start_forward(self, local_host: str, local_port: int):
283284
"""Expose the SSH MITM server on a local TCP port."""
284285
click.echo("Starting local forward (Ctrl+C to stop)...")
@@ -300,18 +301,18 @@ def _start_forward(self, local_host: str, local_port: int):
300301
def execute(self, args) -> SSHMITMCommandRunResult:
301302
"""
302303
Execute command on DUT via gRPC.
303-
304+
304305
The command is run on the exporter using the stored SSH key,
305306
then results are returned.
306307
"""
307308
return_code, stdout, stderr = self.call("execute_command", *args)
308-
309+
309310
return SSHMITMCommandRunResult(
310311
return_code=return_code,
311312
stdout=stdout,
312313
stderr=stderr,
313314
)
314-
315+
315316
def run(self, args) -> SSHMITMCommandRunResult:
316317
"""Alias for execute()."""
317318
return self.execute(args)

0 commit comments

Comments
 (0)