cloudmesh-ai-ssh 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,307 @@
1
+ import click
2
+ import time
3
+ import sys
4
+ import requests
5
+ from pathlib import Path
6
+ from cloudmesh.ai.common.io import console, path_expand, Table
7
+ from cloudmesh.ai.common.logging_utils import get_contextual_logger
8
+ from cloudmesh.ai.common.telemetry import Telemetry
9
+ from cloudmesh.ai.ssh.tunnel import Tunnel
10
+ from cloudmesh.ai.ssh.exceptions import SSHTunnelError
11
+ from cloudmesh.ai.ssh.ssh_config import SSHConfig
12
+
13
+ # Initialize Logger and Telemetry
14
+ logger = get_contextual_logger("ssh")
15
+ telemetry = Telemetry("ssh")
16
+
17
+ def _render_table(rows):
18
+ """Helper to render a list of dictionaries as a rich table."""
19
+ if not rows:
20
+ return None
21
+
22
+ table = Table()
23
+
24
+ # Use keys of the first dictionary as headers
25
+ headers = list(rows[0].keys())
26
+ for header in headers:
27
+ table.add_column(header)
28
+
29
+ # Add rows
30
+ for row in rows:
31
+ table.add_row(*[str(row.get(h, "")) for h in headers])
32
+
33
+ return table
34
+
35
+ # Define the group for the command
36
+ @click.group(name="ssh")
37
+ def ssh_group():
38
+ """ssh command group."""
39
+ pass
40
+
41
+ @click.group(name="list", invoke_without_command=True)
42
+ @click.pass_context
43
+ def list_group(ctx):
44
+ """List SSH configurations and active tunnels."""
45
+ if ctx.invoked_subcommand is None:
46
+ ctx.invoke(hosts_cmd)
47
+
48
+ @list_group.command(name="hosts")
49
+ def hosts_cmd():
50
+ """List all hosts defined in the SSH config file."""
51
+ cfg = SSHConfig()
52
+ hosts = cfg.list()
53
+
54
+ if not hosts:
55
+ console.info("No hosts found in SSH config.")
56
+ return
57
+
58
+ rows = []
59
+ for host in hosts:
60
+ rows.append({
61
+ "Host": host,
62
+ "Hostname": cfg.hostname(host),
63
+ "User": cfg.username(host),
64
+ "Details": cfg.get_options(host)
65
+ })
66
+
67
+ table = _render_table(rows)
68
+ if table:
69
+ console.print(table)
70
+
71
+ @list_group.command(name="tunnel")
72
+ def tunnel_list_cmd():
73
+ """List all tunnels defined in config and their active status."""
74
+ cfg = SSHConfig()
75
+ tunnels = cfg.get_tunnels()
76
+
77
+ if not tunnels:
78
+ console.info("No tunnels defined in SSH config.")
79
+ return
80
+
81
+ from cloudmesh.ai.ssh.base import SSHBase
82
+ checker = SSHBase()
83
+
84
+ rows = []
85
+ for t in tunnels:
86
+ status = "Unknown"
87
+ if t["type"] == "Local":
88
+ try:
89
+ is_open = checker.is_port_open("localhost", int(t["local_port"]))
90
+ status = "Active" if is_open else "Inactive"
91
+ except Exception:
92
+ status = "Error"
93
+
94
+ rows.append({
95
+ "Host": t["host"],
96
+ "Type": t["type"],
97
+ "Local Port": t["local_port"],
98
+ "Remote Target": t["remote_target"],
99
+ "Status": status
100
+ })
101
+
102
+ table = _render_table(rows)
103
+ if table:
104
+ console.print(table)
105
+
106
+ ssh_group.add_command(list_group)
107
+
108
+ @ssh_group.command(name="run")
109
+ def run_cmd():
110
+ """Run the main functionality of ssh."""
111
+ logger.info("Executing ssh run command")
112
+ console.ok(f"The ssh extension is running successfully!")
113
+
114
+ @ssh_group.command(name="tunnel")
115
+ @click.argument("target")
116
+ @click.option("--local-port", type=int, help="Local port to bind to. Defaults to remote port.")
117
+ @click.option("--remote-host", default="localhost", help="Remote host relative to the SSH server. Defaults to localhost.")
118
+ @click.option("--ssh-user", help="SSH username to use.")
119
+ def tunnel_cmd(target, local_port, remote_host, ssh_user):
120
+ """Create an SSH tunnel.
121
+
122
+ Target should be in the format HOST:PORT (e.g., my-server:8000).
123
+ """
124
+ try:
125
+ if ":" not in target:
126
+ raise click.BadParameter("Target must be in the format HOST:PORT")
127
+
128
+ ssh_host, remote_port_str = target.split(":", 1)
129
+ remote_port = int(remote_port_str)
130
+
131
+ # Default local port to remote port if not provided
132
+ l_port = local_port if local_port else remote_port
133
+
134
+ console.info(f"Setting up tunnel: localhost:{l_port} -> {remote_host}:{remote_port} via {ssh_host}")
135
+
136
+ tunnel = Tunnel(
137
+ local_port=l_port,
138
+ remote_host=remote_host,
139
+ remote_port=remote_port,
140
+ ssh_host=ssh_host,
141
+ ssh_user=ssh_user
142
+ )
143
+
144
+ if tunnel.start():
145
+ console.ok(f"Tunnel is active. Press Ctrl+C to stop it.")
146
+ try:
147
+ while True:
148
+ if not tunnel.is_active():
149
+ console.warn("Tunnel process terminated unexpectedly.")
150
+ break
151
+ time.sleep(1)
152
+ except KeyboardInterrupt:
153
+ console.info("\nStopping tunnel...")
154
+ tunnel.stop()
155
+ console.ok("Tunnel closed.")
156
+ else:
157
+ console.error("Failed to start tunnel.")
158
+ sys.exit(1)
159
+
160
+ except ValueError:
161
+ raise click.BadParameter("Port must be a valid integer.")
162
+ except SSHTunnelError as e:
163
+ console.error(f"SSH Tunnel Error: {e}")
164
+ sys.exit(1)
165
+ except Exception as e:
166
+ console.error(f"Unexpected error: {e}")
167
+ sys.exit(1)
168
+
169
+ @ssh_group.command(name="test-path")
170
+ @click.argument("path")
171
+ def test_path_cmd(path):
172
+ """Example command showing path expansion."""
173
+ expanded = path_expand(path)
174
+ console.info(f"Expanded path: {expanded}")
175
+
176
+ @click.group(name="check", invoke_without_command=True)
177
+ @click.pass_context
178
+ def check_group(ctx):
179
+ """Check the SSH config file for malformed entries."""
180
+ if ctx.invoked_subcommand is None:
181
+ ctx.invoke(check_basic)
182
+
183
+ @check_group.command(name="basic")
184
+ def check_basic():
185
+ """Run basic SSH config validation."""
186
+ cfg = SSHConfig()
187
+ errors = cfg.check()
188
+
189
+ if not errors:
190
+ console.ok("SSH config is valid!")
191
+ return
192
+
193
+ console.print(f"[yellow]Found {len(errors)} issue(s) in {cfg.filename}:[/yellow]")
194
+
195
+ rows = []
196
+ for err in errors:
197
+ message = err["message"]
198
+ if ": " in message:
199
+ category, details = message.split(": ", 1)
200
+ else:
201
+ category, details = message, ""
202
+
203
+ rows.append({
204
+ "Line": err["line"],
205
+ "Category": category,
206
+ "Details": details
207
+ })
208
+
209
+ table = _render_table(rows)
210
+ if table:
211
+ console.print(table)
212
+
213
+ @check_group.command(name="ai")
214
+ @click.option("--api-key", default=None, help="API key for the vLLM server.")
215
+ def check_ai(api_key):
216
+ """Gather diagnostic data and submit it to the vLLM server for repair."""
217
+ cfg = SSHConfig()
218
+
219
+ console.info("Gathering SSH configuration diagnostics...")
220
+
221
+ # 1. Get malformed entries
222
+ errors = cfg.check()
223
+
224
+ # 2. Get raw config content
225
+ content = cfg.get_content()
226
+
227
+ # 3. Package the data
228
+ diag_data = {
229
+ "config_file": str(cfg.filename),
230
+ "raw_content": content,
231
+ "errors": errors
232
+ }
233
+
234
+ # 4. Submit to the vLLM server (OpenAI compatible API)
235
+ base_url = "http://localhost:17704"
236
+
237
+ # Load default API key if none provided
238
+ if not api_key:
239
+ try:
240
+ key_path = Path("~/gemma/server_master_key.txt").expanduser()
241
+ if key_path.exists():
242
+ api_key = key_path.read_text().strip()
243
+ except Exception as e:
244
+ logger.debug(f"Could not load default API key from ~/gemma/server_master_key.txt: {e}")
245
+
246
+ headers = {}
247
+ if api_key:
248
+ headers["Authorization"] = f"Bearer {api_key}"
249
+
250
+ try:
251
+ # a. Get the model name from the vLLM server
252
+ model_response = requests.get(f"{base_url}/v1/models", headers=headers, timeout=10)
253
+ model_response.raise_for_status()
254
+ models = model_response.json().get("data", [])
255
+ if not models:
256
+ raise Exception("No models found on the vLLM server.")
257
+ model_name = models[0]["id"]
258
+
259
+ # b. Construct the prompt for the LLM
260
+ system_prompt = (
261
+ "You are an expert SSH configuration assistant. Your goal is to analyze the provided "
262
+ "SSH config diagnostics and provide a corrected version of the config file. "
263
+ "Explain the issues found and provide the final corrected content clearly."
264
+ )
265
+ user_prompt = f"Please analyze these SSH config diagnostics and provide a fix:\n\n{diag_data}"
266
+
267
+ payload = {
268
+ "model": model_name,
269
+ "messages": [
270
+ {"role": "system", "content": system_prompt},
271
+ {"role": "user", "content": user_prompt}
272
+ ],
273
+ "temperature": 0.2
274
+ }
275
+
276
+ console.info(f"Sending diagnostics to vLLM model {model_name} at {base_url}/v1/chat/completions...")
277
+
278
+ response = requests.post(f"{base_url}/v1/chat/completions", json=payload, headers=headers, timeout=60)
279
+ response.raise_for_status()
280
+
281
+ result = response.json()
282
+ answer = result["choices"][0]["message"]["content"]
283
+
284
+ console.ok("AI Analysis complete!")
285
+ console.print(f"\n[bold green]AI Repair Suggestion:[/bold green]\n\n{answer}")
286
+
287
+ except requests.exceptions.ConnectionError:
288
+ console.error(f"Could not connect to the vLLM server at {base_url}. Is it running?")
289
+ _save_fallback(diag_data)
290
+ except Exception as e:
291
+ console.error(f"An error occurred during AI analysis: {e}")
292
+ _save_fallback(diag_data)
293
+
294
+ def _save_fallback(diag_data):
295
+ diag_file = Path("ssh_diag.json")
296
+ import json
297
+ with open(diag_file, "w") as f:
298
+ json.dump(diag_data, f, indent=4)
299
+ console.print(f"[yellow]Diagnostic data saved to {diag_file} as fallback.[/yellow]")
300
+
301
+ ssh_group.add_command(check_group)
302
+
303
+ entry_point = ssh_group
304
+
305
+ def register(cli):
306
+ """Registers the ssh command group to the main CLI."""
307
+ cli.add_command(ssh_group, name="ssh")
@@ -0,0 +1,4 @@
1
+ from .tunnel import Tunnel
2
+ from .ssh_config import SSHConfig
3
+
4
+ __all__ = ["Tunnel", "SSHConfig"]
@@ -0,0 +1,128 @@
1
+ # Copyright 2026 Gregor von Laszewski
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ import io
10
+ from pathlib import Path
11
+ from typing import Dict, List, Optional, Union
12
+
13
+ from cloudmesh.ai.common import logging as ai_log
14
+ from .base import SSHBase
15
+ from .exceptions import SSHError
16
+
17
+ logger = ai_log.get_logger("ai.ssh.authorized_keys")
18
+
19
+
20
+ class AuthorizedKeys(SSHBase):
21
+ """Class to manage authorized keys."""
22
+
23
+ def __init__(self, debug: bool = False):
24
+ super().__init__(debug=debug)
25
+ self._order: Dict[int, str] = {}
26
+ self._keys: Dict[str, str] = {}
27
+
28
+ def get_fingerprint_from_public_key(self, pubkey: str) -> str:
29
+ """Generate the fingerprint of a public key.
30
+
31
+ Args:
32
+ pubkey (str): the value of the public key
33
+
34
+ Returns:
35
+ str: fingerprint
36
+ """
37
+ try:
38
+ # Use ssh-keygen -l -f /dev/stdin to avoid creating temporary files
39
+ process = self._execute(
40
+ ["ssh-keygen", "-l", "-f", "/dev/stdin"],
41
+ input_data=pubkey
42
+ )
43
+ output = process.stdout.strip()
44
+ # Output format: "2048 SHA256:abc... user@host (RSA)"
45
+ parts = output.split(' ')
46
+ if len(parts) >= 2:
47
+ return parts[1]
48
+ return output
49
+ except Exception as e:
50
+ logger.error(f"Failed to get fingerprint for public key: {e}")
51
+ return ""
52
+
53
+ def load(self, path: Union[str, Path]) -> None:
54
+ """Load the keys from a path.
55
+
56
+ Args:
57
+ path: the filename (path) in which we find the keys
58
+ """
59
+ path = self.resolve_path(str(path))
60
+ if not path.exists():
61
+ logger.warning(f"Authorized keys file not found: {path}")
62
+ return
63
+
64
+ try:
65
+ with path.open('r') as fd:
66
+ for pubkey in map(str.strip, fd):
67
+ # skip empty lines and comments
68
+ if not pubkey or pubkey.startswith('#'):
69
+ continue
70
+ self.add(pubkey)
71
+ except Exception as e:
72
+ logger.error(f"Error loading authorized keys from {path}: {e}")
73
+
74
+ def add(self, pubkey: str):
75
+ """Add a public key.
76
+
77
+ Args:
78
+ pubkey: the public key string.
79
+ """
80
+ fingerprint = self.get_fingerprint_from_public_key(pubkey)
81
+ if not fingerprint:
82
+ logger.warning("Could not generate fingerprint for public key; skipping.")
83
+ return
84
+
85
+ if fingerprint not in self._keys:
86
+ self._order[len(self._keys)] = fingerprint
87
+ self._keys[fingerprint] = pubkey
88
+
89
+ def remove(self, fingerprint: str):
90
+ """Removes the public key by its fingerprint.
91
+
92
+ Args:
93
+ fingerprint: the fingerprint of the public key to remove.
94
+ """
95
+ if fingerprint in self._keys:
96
+ del self._keys[fingerprint]
97
+ # Rebuild order to keep it contiguous
98
+ new_order = {}
99
+ for i, f in enumerate(self._order.values()):
100
+ if f != fingerprint:
101
+ new_order[i] = f
102
+ self._order = new_order
103
+ else:
104
+ logger.warning(f"Fingerprint {fingerprint} not found in authorized keys.")
105
+
106
+ def __str__(self) -> str:
107
+ with io.StringIO() as sio:
108
+ for fingerprint in self._order.values():
109
+ key = self._keys.get(fingerprint)
110
+ if key:
111
+ sio.write(key)
112
+ sio.write('\n')
113
+ return sio.getvalue().strip()
114
+
115
+ def __repr__(self) -> str:
116
+ return f"AuthorizedKeys(keys={len(self._keys)})"
117
+
118
+
119
+ if __name__ == '__main__':
120
+ import sys
121
+ if len(sys.argv) < 2:
122
+ print("Usage: python authorized_keys.py <path_to_authorized_keys>")
123
+ sys.exit(1)
124
+
125
+ path = sys.argv[1]
126
+ auth = AuthorizedKeys()
127
+ auth.load(path)
128
+ print(auth)
@@ -0,0 +1,183 @@
1
+ # Copyright 2026 Gregor von Laszewski
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ import subprocess
10
+ import socket
11
+
12
+ from pathlib import Path
13
+ from typing import List, Optional, Dict, Union
14
+ from dataclasses import dataclass
15
+ from fabric import Connection
16
+ from cloudmesh.ai.common import logging as ai_log
17
+ from .exceptions import SSHError, SSHConnectionError
18
+
19
+ logger = ai_log.get_logger("ai.ssh.base")
20
+
21
+ @dataclass
22
+ class CommandResult:
23
+ """Structured result of a remote command execution."""
24
+ stdout: str
25
+ stderr: str
26
+ exit_code: int
27
+ command: str
28
+ host: str
29
+
30
+ class SSHBase:
31
+ """Base class for SSH utilities providing shared execution and path logic."""
32
+
33
+ def __init__(self, debug: bool = False):
34
+ self.debug = debug
35
+ self._connection_pool: Dict[str, Connection] = {}
36
+
37
+ def _execute(self, command: List[str], input_data: Optional[str] = None, capture_output: bool = True) -> subprocess.CompletedProcess:
38
+ """Execute a system command using subprocess.run.
39
+
40
+ Args:
41
+ command: The command and arguments as a list.
42
+ input_data: Optional string to pass to stdin.
43
+ capture_output: Whether to capture stdout and stderr.
44
+
45
+ Returns:
46
+ The CompletedProcess object.
47
+ """
48
+ if self.debug:
49
+ logger.debug(f"Executing command: {' '.join(command)}")
50
+
51
+ try:
52
+ return subprocess.run(
53
+ command,
54
+ input=input_data,
55
+ capture_output=capture_output,
56
+ text=True,
57
+ check=True
58
+ )
59
+ except subprocess.CalledProcessError as e:
60
+ logger.error(f"Command failed: {e.stderr}")
61
+ raise SSHError(f"System command failed: {e.stderr}") from e
62
+
63
+ def resolve_path(self, path: Union[str, Path]) -> Path:
64
+ """Expand and resolve a path."""
65
+ return Path(path).expanduser().resolve()
66
+
67
+ def is_port_open(self, host: str, port: int, timeout: float = 1.0) -> bool:
68
+ """Check if a port is open on a given host.
69
+
70
+ Args:
71
+ host: the hostname or IP address.
72
+ port: the port number.
73
+ timeout: timeout in seconds.
74
+
75
+ Returns:
76
+ bool: True if the port is open, False otherwise.
77
+ """
78
+ try:
79
+ import socket
80
+ with socket.create_connection((host, port), timeout=timeout):
81
+ return True
82
+ except (socket.timeout, ConnectionRefusedError, OSError):
83
+ return False
84
+
85
+ def _get_connection(self, host: str, user: Optional[str] = None) -> Connection:
86
+ """Get a Fabric connection from the pool or create a new one, with health check.
87
+
88
+ Args:
89
+ host: the hostname or alias.
90
+ user: optional username.
91
+
92
+ Returns:
93
+ Connection: a Fabric connection object.
94
+ """
95
+ pool_key = f"{user}@{host}" if user else host
96
+ conn = self._connection_pool.get(pool_key)
97
+
98
+ if conn:
99
+ try:
100
+ # Heartbeat check: run a lightweight command to verify connection
101
+ conn.run("true", hide=True, timeout=5)
102
+ except Exception:
103
+ if self.debug:
104
+ logger.debug(f"Connection for {pool_key} is stale, recreating...")
105
+ conn = None
106
+
107
+ if conn is None:
108
+ if self.debug:
109
+ logger.debug(f"Creating new connection for {pool_key}")
110
+ try:
111
+ conn = Connection(host=host, user=user)
112
+ self._connection_pool[pool_key] = conn
113
+ except Exception as e:
114
+ raise SSHConnectionError(f"Failed to create connection to {host}: {e}") from e
115
+
116
+ return conn
117
+
118
+ def _run_remote(self, host: str, command: str, user: Optional[str] = None, use_sudo: bool = False, use_pty: bool = False) -> CommandResult:
119
+ """Execute a command on a remote host using Fabric.
120
+
121
+ Args:
122
+ host: the hostname or alias.
123
+ command: the command to execute.
124
+ user: optional username.
125
+ use_sudo: whether to use sudo for execution.
126
+ use_pty: whether to allocate a pseudo-terminal.
127
+
128
+ Returns:
129
+ CommandResult: structured result of the execution.
130
+ """
131
+ if self.debug:
132
+ logger.debug(f"Executing {'sudo ' if use_sudo else ''}remote command on {host} (pty={use_pty}): {command}")
133
+
134
+ try:
135
+ conn = self._get_connection(host, user)
136
+ if use_sudo:
137
+ result = conn.sudo(command, hide=True, pty=use_pty)
138
+ else:
139
+ result = conn.run(command, hide=True, pty=use_pty)
140
+
141
+ return CommandResult(
142
+ stdout=result.stdout.strip(),
143
+ stderr=result.stderr.strip(),
144
+ exit_code=result.exited,
145
+ command=command,
146
+ host=host
147
+ )
148
+ except Exception as e:
149
+ logger.error(f"Fabric execution failed on {host}: {e}")
150
+ raise SSHConnectionError(f"Fabric execution failed on {host}: {e}") from e
151
+
152
+ def put(self, local_path: Union[str, Path], remote_path: Union[str, Path], host: str, user: Optional[str] = None) -> None:
153
+ """Upload a local file to a remote host."""
154
+ if self.debug:
155
+ logger.debug(f"Uploading {local_path} to {host}:{remote_path}")
156
+ try:
157
+ conn = self._get_connection(host, user)
158
+ conn.put(str(local_path), str(remote_path))
159
+ except Exception as e:
160
+ logger.error(f"Fabric put failed on {host}: {e}")
161
+ raise SSHConnectionError(f"Fabric put failed on {host}: {e}") from e
162
+
163
+ def get(self, remote_path: Union[str, Path], local_path: Union[str, Path], host: str, user: Optional[str] = None) -> None:
164
+ """Download a remote file to a local path."""
165
+ if self.debug:
166
+ logger.debug(f"Downloading {remote_path} from {host} to {local_path}")
167
+ try:
168
+ conn = self._get_connection(host, user)
169
+ conn.get(str(remote_path), str(local_path))
170
+ except Exception as e:
171
+ logger.error(f"Fabric get failed on {host}: {e}")
172
+ raise SSHConnectionError(f"Fabric get failed on {host}: {e}") from e
173
+
174
+ def close_connections(self) -> None:
175
+ """Close all active connections in the pool."""
176
+ if self.debug:
177
+ logger.debug("Closing all SSH connections in pool.")
178
+ for conn in self._connection_pool.values():
179
+ try:
180
+ conn.close()
181
+ except Exception as e:
182
+ logger.error(f"Error closing connection: {e}")
183
+ self._connection_pool.clear()