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

Commit 3caa8ea

Browse files
committed
Add new ssh-mitm driver to jumpstarter
Signed-off-by: Bella Khizgiyaev <bkhizgiy@redhat.com>
1 parent 8ef8ada commit 3caa8ea

7 files changed

Lines changed: 1362 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
apiVersion: jumpstarter.dev/v1alpha1
2+
kind: ExporterConfig
3+
metadata:
4+
namespace: default
5+
name: ssh-mitm-example
6+
export:
7+
ssh_mitm:
8+
type: jumpstarter_driver_ssh_mitm.driver.SSHMITM
9+
config:
10+
default_username: "root"
11+
# Option 1: Provide SSH key directly in config
12+
ssh_identity: |
13+
-----BEGIN OPENSSH PRIVATE KEY-----
14+
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAFwAAAAdz
15+
c2gtcnNhAAAAAwEAAQAAAQEAy... (your private key here)
16+
-----END OPENSSH PRIVATE KEY-----
17+
# Option 2: Or provide path to key file (uncomment to use)
18+
# ssh_identity_file: "/path/to/your/private/key"
19+
children:
20+
tcp:
21+
type: jumpstarter_driver_network.driver.TcpNetwork
22+
config:
23+
host: "192.168.1.100"
24+
port: 22

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

Whitespace-only changes.
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
"""
2+
SSH MITM Client - Secure SSH access without exposing private keys.
3+
4+
This client connects to the SSHMITM driver which keeps SSH private keys
5+
secure on the exporter. Commands can be executed via gRPC or by tunneling
6+
through a local SSH port that is forwarded to the driver's MITM proxy.
7+
8+
Usage:
9+
j ssh_mitm <cmd> # Execute command via gRPC
10+
j ssh_mitm shell # Native SSH via port forwarding
11+
j ssh_mitm shell --repl # Interactive gRPC REPL shell
12+
j ssh_mitm forward -p 2222 # Port forward for ssh/scp/rsync
13+
"""
14+
15+
import os
16+
import shlex
17+
import shutil
18+
import subprocess
19+
import time
20+
import textwrap
21+
import uuid
22+
from dataclasses import dataclass
23+
from difflib import get_close_matches
24+
25+
import click
26+
from jumpstarter_driver_network.adapters import TcpPortforwardAdapter
27+
28+
from jumpstarter.client import DriverClient
29+
30+
31+
class DefaultCommandGroup(click.Group):
32+
"""
33+
Click group that falls back to a default subcommand when none matches,
34+
but only if the user didn't intend to invoke an actual subcommand.
35+
"""
36+
37+
def __init__(self, *args, default_command: str | None = None, **kwargs):
38+
self.default_command = default_command
39+
super().__init__(*args, **kwargs)
40+
41+
def resolve_command(self, ctx, args):
42+
try:
43+
return super().resolve_command(ctx, args)
44+
except click.UsageError as original_error:
45+
if self.default_command is None or not args:
46+
raise
47+
48+
# If the first argument is close to an existing subcommand,
49+
# treat it as a typo and re-raise the original error.
50+
first_token = args[0]
51+
subcommand_names = list(self.commands.keys())
52+
close_matches = get_close_matches(first_token, subcommand_names, n=1, cutoff=0.8)
53+
if close_matches:
54+
raise original_error
55+
56+
cmd = self.get_command(ctx, self.default_command)
57+
return cmd.name, cmd, args
58+
59+
60+
@dataclass
61+
class SSHMITMCommandRunResult:
62+
"""Result of executing a command via SSH MITM."""
63+
return_code: int
64+
stdout: str
65+
stderr: str
66+
67+
68+
@dataclass(kw_only=True)
69+
class SSHMITMClient(DriverClient):
70+
"""
71+
Client for SSH MITM proxy driver.
72+
73+
Provides secure SSH access where the private key never leaves the exporter.
74+
Commands are executed via gRPC - the driver runs SSH on behalf of the client.
75+
"""
76+
77+
def cli(self):
78+
"""Create CLI command for 'j ssh_mitm'."""
79+
client = self
80+
81+
@click.group(
82+
"ssh",
83+
cls=DefaultCommandGroup,
84+
default_command="run",
85+
invoke_without_command=True,
86+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
87+
help=client.description or "SSH MITM - secure SSH to DUT",
88+
)
89+
def ssh_cmd():
90+
"""SSH MITM group."""
91+
pass
92+
93+
@ssh_cmd.command(
94+
"run",
95+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
96+
)
97+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
98+
@click.pass_context
99+
def run(ctx, args):
100+
"""Default command execution via gRPC."""
101+
if not args:
102+
click.echo("Usage:")
103+
click.echo(" j ssh_mitm <command> [args...]")
104+
click.echo(" j ssh_mitm shell [ssh options/remote command]")
105+
click.echo(" j ssh_mitm forward [--port PORT]")
106+
click.echo("\nExamples:")
107+
click.echo(" j ssh_mitm whoami")
108+
click.echo(" j ssh_mitm ls -la /tmp")
109+
click.echo(" j ssh_mitm shell")
110+
click.echo(" j ssh_mitm forward -p 2222")
111+
return
112+
113+
result = client.execute(args)
114+
115+
if result.stdout:
116+
click.echo(result.stdout, nl=False)
117+
if result.stderr:
118+
click.echo(result.stderr, nl=False, err=True)
119+
120+
if result.return_code != 0:
121+
ctx.exit(result.return_code)
122+
123+
@ssh_cmd.command("shell")
124+
@click.option(
125+
"--repl",
126+
is_flag=True,
127+
help="Use simple gRPC REPL instead of launching native ssh",
128+
)
129+
@click.argument("ssh_args", nargs=-1, type=click.UNPROCESSED)
130+
@click.pass_context
131+
def shell(ctx, repl, ssh_args):
132+
"""
133+
Launch an SSH session through the MITM proxy.
134+
135+
By default, spawns the system 'ssh' binary via port forwarding.
136+
Use --repl for the lightweight gRPC REPL shell.
137+
"""
138+
if repl:
139+
client._run_shell()
140+
else:
141+
exit_code = client._launch_native_ssh(ssh_args)
142+
if exit_code != 0:
143+
ctx.exit(exit_code)
144+
145+
@ssh_cmd.command("forward")
146+
@click.option(
147+
"--host",
148+
"local_host",
149+
default="127.0.0.1",
150+
show_default=True,
151+
help="Local interface to bind",
152+
)
153+
@click.option(
154+
"-p",
155+
"--port",
156+
"local_port",
157+
type=int,
158+
default=0,
159+
help="Local port (0 = auto)",
160+
show_default=True,
161+
)
162+
def forward(local_host, local_port):
163+
"""
164+
Expose the MITM proxy as a local TCP port for native SSH/scp/rsync.
165+
166+
Example:
167+
j ssh_mitm forward -p 2222
168+
ssh -p 2222 localhost
169+
"""
170+
client._start_forward(local_host, local_port)
171+
172+
return ssh_cmd
173+
174+
def _ensure_ssh_binary(self) -> str:
175+
ssh_path = shutil.which("ssh")
176+
if not ssh_path:
177+
raise click.ClickException("'ssh' binary not found in PATH")
178+
return ssh_path
179+
180+
def _launch_native_ssh(self, ssh_args: tuple[str, ...]) -> int:
181+
username = self.call("get_default_username") or os.environ.get("USER", "root")
182+
ssh_binary = self._ensure_ssh_binary()
183+
184+
with TcpPortforwardAdapter(client=self, method="connect") as (host, port):
185+
ssh_command = [
186+
ssh_binary,
187+
"-p",
188+
str(port),
189+
"-o",
190+
"StrictHostKeyChecking=no",
191+
"-o",
192+
"UserKnownHostsFile=/dev/null",
193+
f"{username}@{host}",
194+
]
195+
if ssh_args:
196+
ssh_command.extend(ssh_args)
197+
198+
self.logger.debug("Launching native SSH: %s", shlex.join(ssh_command))
199+
return subprocess.call(ssh_command)
200+
201+
def _run_shell(self):
202+
"""Run interactive shell via gRPC commands."""
203+
username = self.call("get_default_username") or "user"
204+
hostname = "dut"
205+
206+
try:
207+
result = self.execute(["hostname", "-s"])
208+
if result.return_code == 0 and result.stdout.strip():
209+
hostname = result.stdout.strip()
210+
except Exception as e:
211+
self.logger.debug("Failed to get hostname: %s", e)
212+
213+
click.echo(f"Connected to {hostname} via SSH MITM proxy")
214+
click.echo("Type 'exit' or Ctrl+D to exit")
215+
click.echo()
216+
217+
cwd = "~"
218+
219+
while True:
220+
try:
221+
prompt = click.style(f"{username}@{hostname}", fg="green", bold=True)
222+
prompt += click.style(":", fg="white")
223+
prompt += click.style(cwd, fg="blue", bold=True)
224+
prompt += click.style("$ ", fg="white")
225+
226+
cmd = input(prompt)
227+
228+
if not cmd.strip():
229+
continue
230+
231+
if cmd.strip() == "exit":
232+
click.echo("Connection closed.")
233+
break
234+
235+
if cmd.strip().startswith("cd "):
236+
new_dir = cmd.strip()[3:].strip()
237+
result = self.execute(
238+
[
239+
"bash",
240+
"-c",
241+
f'cd {shlex.quote(cwd)} 2>/dev/null; cd {shlex.quote(new_dir)} && pwd',
242+
]
243+
)
244+
if result.return_code == 0 and result.stdout.strip():
245+
cwd = result.stdout.strip()
246+
else:
247+
click.echo(f"cd: {new_dir}: No such file or directory", err=True)
248+
continue
249+
250+
if cmd.strip() == "cd":
251+
cwd = "~"
252+
continue
253+
254+
# Execute command in current directory using newline-delimited heredoc to avoid interpolation
255+
token = f"JSSHMITM_{uuid.uuid4().hex}"
256+
script = (
257+
textwrap.dedent(
258+
f"""
259+
cd {shlex.quote(cwd)} 2>/dev/null || cd ~
260+
cat <<'{token}' | bash
261+
{cmd}
262+
{token}
263+
"""
264+
).strip()
265+
+ "\n"
266+
)
267+
result = self.execute(["bash", "-lc", script])
268+
269+
if result.stdout:
270+
click.echo(result.stdout, nl=False)
271+
if result.stderr:
272+
click.echo(result.stderr, nl=False, err=True)
273+
274+
except EOFError:
275+
click.echo()
276+
click.echo("Connection closed.")
277+
break
278+
except KeyboardInterrupt:
279+
click.echo("^C")
280+
continue
281+
282+
def _start_forward(self, local_host: str, local_port: int):
283+
"""Expose the SSH MITM server on a local TCP port."""
284+
click.echo("Starting local forward (Ctrl+C to stop)...")
285+
try:
286+
with TcpPortforwardAdapter(
287+
client=self,
288+
method="connect",
289+
local_host=local_host,
290+
local_port=local_port,
291+
) as (bound_host, bound_port):
292+
click.echo(f"Local endpoint: {bound_host}:{bound_port}")
293+
click.echo(f"Example: ssh -p {bound_port} localhost")
294+
click.echo("Press Ctrl+C to stop forwarding.")
295+
while True:
296+
time.sleep(1)
297+
except KeyboardInterrupt:
298+
click.echo("\nForward stopped.")
299+
300+
def execute(self, args) -> SSHMITMCommandRunResult:
301+
"""
302+
Execute command on DUT via gRPC.
303+
304+
The command is run on the exporter using the stored SSH key,
305+
then results are returned.
306+
"""
307+
return_code, stdout, stderr = self.call("execute_command", *args)
308+
309+
return SSHMITMCommandRunResult(
310+
return_code=return_code,
311+
stdout=stdout,
312+
stderr=stderr,
313+
)
314+
315+
def run(self, args) -> SSHMITMCommandRunResult:
316+
"""Alias for execute()."""
317+
return self.execute(args)

0 commit comments

Comments
 (0)