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.
- cloudmesh/ai/command/ssh.py +307 -0
- cloudmesh/ai/ssh/__init__.py +4 -0
- cloudmesh/ai/ssh/authorized_keys.py +128 -0
- cloudmesh/ai/ssh/base.py +183 -0
- cloudmesh/ai/ssh/encryption.py +191 -0
- cloudmesh/ai/ssh/exceptions.py +27 -0
- cloudmesh/ai/ssh/ssh_config.py +451 -0
- cloudmesh/ai/ssh/transfer.py +82 -0
- cloudmesh/ai/ssh/tunnel.py +185 -0
- cloudmesh_ai_ssh-0.1.0.dist-info/METADATA +134 -0
- cloudmesh_ai_ssh-0.1.0.dist-info/RECORD +14 -0
- cloudmesh_ai_ssh-0.1.0.dist-info/WHEEL +5 -0
- cloudmesh_ai_ssh-0.1.0.dist-info/entry_points.txt +2 -0
- cloudmesh_ai_ssh-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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)
|
cloudmesh/ai/ssh/base.py
ADDED
|
@@ -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()
|