tunnel-manager 0.0.5__py3-none-any.whl → 1.0.1__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.

Potentially problematic release.


This version of tunnel-manager might be problematic. Click here for more details.

@@ -1,272 +1,2098 @@
1
- #!/usr/bin/python
1
+ #!/usr/bin/env python
2
2
  # coding: utf-8
3
- import argparse
4
3
  import os
5
4
  import sys
5
+ import argparse
6
6
  import logging
7
- from typing import Optional
7
+ import concurrent.futures
8
+ import yaml
9
+ import asyncio
10
+ from typing import Optional, Dict, List, Union
8
11
  from tunnel_manager.tunnel_manager import Tunnel
9
12
  from fastmcp import FastMCP, Context
10
13
  from pydantic import Field
11
14
 
15
+ # Initialize FastMCP
16
+ mcp = FastMCP(name="TunnelServer")
17
+
18
+ # Configure default logging
12
19
  logging.basicConfig(
13
20
  filename="tunnel_mcp.log",
14
21
  level=logging.INFO,
15
- format="%(asctime)s - %(levelname)s - %(message)s",
22
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
16
23
  )
17
24
 
18
- mcp = FastMCP(name="TunnelServer")
25
+
26
+ def to_boolean(string: Union[str, bool] = None) -> bool:
27
+ if isinstance(string, bool):
28
+ return string
29
+ if not string:
30
+ return False
31
+ normalized = str(string).strip().lower()
32
+ true_values = {"t", "true", "y", "yes", "1"}
33
+ false_values = {"f", "false", "n", "no", "0"}
34
+ if normalized in true_values:
35
+ return True
36
+ elif normalized in false_values:
37
+ return False
38
+ else:
39
+ raise ValueError(f"Cannot convert '{string}' to boolean")
40
+
41
+
42
+ def to_integer(string: Union[str, int] = None) -> int:
43
+ if isinstance(string, int):
44
+ return string
45
+ if not string:
46
+ return 0
47
+ try:
48
+ return int(string.strip())
49
+ except ValueError:
50
+ raise ValueError(f"Cannot convert '{string}' to integer")
51
+
52
+
53
+ class ResponseBuilder:
54
+ @staticmethod
55
+ def build(
56
+ status: int,
57
+ msg: str,
58
+ details: Dict,
59
+ error: str = "",
60
+ stdout: str = "", # Add this
61
+ files: List = None,
62
+ locations: List = None,
63
+ errors: List = None,
64
+ ) -> Dict:
65
+ return {
66
+ "status_code": status,
67
+ "message": msg,
68
+ "stdout": stdout, # Use the parameter
69
+ "stderr": error,
70
+ "files_copied": files or [],
71
+ "locations_copied_to": locations or [],
72
+ "details": details,
73
+ "errors": errors or ([error] if error else []),
74
+ }
75
+
76
+
77
+ def setup_logging(log_file: Optional[str], logger: logging.Logger) -> Dict:
78
+ if not log_file:
79
+ return {}
80
+ try:
81
+ log_dir = os.path.dirname(os.path.abspath(log_file)) or os.getcwd()
82
+ os.makedirs(log_dir, exist_ok=True)
83
+ logging.basicConfig(
84
+ filename=log_file,
85
+ level=logging.DEBUG,
86
+ format="%(asctime)s - %(name)s - %(level)s - %(msg)s",
87
+ )
88
+ return {}
89
+ except Exception as e:
90
+ logger.error(f"Log config fail: {e}")
91
+ return ResponseBuilder.build(500, f"Log config fail: {e}", {}, str(e))
92
+
93
+
94
+ def load_inventory(
95
+ inventory: str, group: str, logger: logging.Logger
96
+ ) -> tuple[List[Dict], Dict]:
97
+ try:
98
+ with open(inventory, "r") as f:
99
+ inv = yaml.safe_load(f)
100
+ hosts = []
101
+ if group in inv and isinstance(inv[group], dict) and "hosts" in inv[group]:
102
+ for host, vars in inv[group]["hosts"].items():
103
+ entry = {
104
+ "hostname": vars.get("ansible_host", host),
105
+ "username": vars.get("ansible_user"),
106
+ "password": vars.get("ansible_ssh_pass"),
107
+ "key_path": vars.get("ansible_ssh_private_key_file"),
108
+ }
109
+ if not entry["username"]:
110
+ logger.error(f"Skip {entry['hostname']}: no username")
111
+ continue
112
+ hosts.append(entry)
113
+ else:
114
+ return [], ResponseBuilder.build(
115
+ 400,
116
+ f"Group '{group}' invalid",
117
+ {"inventory": inventory, "group": group},
118
+ errors=[f"Group '{group}' invalid"],
119
+ )
120
+ if not hosts:
121
+ return [], ResponseBuilder.build(
122
+ 400,
123
+ f"No hosts in group '{group}'",
124
+ {"inventory": inventory, "group": group},
125
+ errors=[f"No hosts in group '{group}'"],
126
+ )
127
+ return hosts, {}
128
+ except Exception as e:
129
+ logger.error(f"Load inv fail: {e}")
130
+ return [], ResponseBuilder.build(
131
+ 500,
132
+ f"Load inv fail: {e}",
133
+ {"inventory": inventory, "group": group},
134
+ str(e),
135
+ )
19
136
 
20
137
 
21
138
  @mcp.tool(
22
139
  annotations={
23
- "title": "Run Remote Command",
140
+ "title": "Run Command on Remote Host",
24
141
  "readOnlyHint": True,
25
142
  "destructiveHint": True,
26
143
  "idempotentHint": False,
27
- "openWorldHint": False,
28
144
  },
29
145
  tags={"remote_access"},
30
146
  )
31
- async def run_remote_command(
32
- remote_host: str = Field(
33
- description="The remote host to connect to.",
34
- default=os.environ.get("TUNNEL_REMOTE_HOST", None),
147
+ async def run_command_on_remote_host(
148
+ host: str = Field(
149
+ description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
150
+ ),
151
+ user: Optional[str] = Field(
152
+ description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
35
153
  ),
36
- remote_port: str = Field(
37
- description="The remote host's port to connect to.",
38
- default=os.environ.get("TUNNEL_REMOTE_PORT", None),
154
+ password: Optional[str] = Field(
155
+ description="Password.", default=os.environ.get("TUNNEL_PASSWORD", None)
39
156
  ),
40
- command: str = Field(
41
- description="The shell command to run on the remote host.", default=None
157
+ port: int = Field(
158
+ description="Port.",
159
+ default=to_integer(os.environ.get("TUNNEL_REMOTE_PORT", "22")),
42
160
  ),
43
- identity_file: Optional[str] = Field(
44
- description="Path to the private key file.",
161
+ cmd: str = Field(description="Shell command.", default=None),
162
+ id_file: Optional[str] = Field(
163
+ description="Private key path.",
45
164
  default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
46
165
  ),
47
- certificate_file: Optional[str] = Field(
48
- description="Path to the certificate file (for Teleport).",
166
+ certificate: Optional[str] = Field(
167
+ description="Teleport certificate.",
49
168
  default=os.environ.get("TUNNEL_CERTIFICATE", None),
50
169
  ),
51
- proxy_command: Optional[str] = Field(
52
- description="Proxy command (for Teleport).",
170
+ proxy: Optional[str] = Field(
171
+ description="Teleport proxy.",
53
172
  default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
54
173
  ),
55
- log_file: Optional[str] = Field(
56
- description="Path to log file for this operation.",
57
- default=os.environ.get("TUNNEL_LOG_FILE", None),
174
+ cfg: str = Field(
175
+ description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
58
176
  ),
59
- ctx: Context = Field(
60
- description="MCP context for progress reporting.", default=None
177
+ log: Optional[str] = Field(
178
+ description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
61
179
  ),
62
- ) -> str:
63
- """Runs a shell command on a remote host via SSH or Teleport."""
180
+ ctx: Context = Field(description="MCP context.", default=None),
181
+ ) -> Dict:
182
+ """Run shell command on remote host. Expected return object type: dict"""
64
183
  logger = logging.getLogger("TunnelServer")
65
- logger.debug(
66
- f"Starting run_remote_command for host: {remote_host}, command: {command}"
67
- )
68
-
69
- if not remote_host or not command:
70
- raise ValueError("remote_host and command must be provided.")
71
-
184
+ if error := setup_logging(log, logger):
185
+ return error
186
+ logger.debug(f"Run cmd: host={host}, cmd={cmd}")
187
+ if not host or not cmd:
188
+ logger.error("Need host, cmd")
189
+ return ResponseBuilder.build(
190
+ 400, "Need host, cmd", {"host": host, "cmd": cmd}, errors=["Need host, cmd"]
191
+ )
72
192
  try:
73
- tunnel = Tunnel(
74
- remote_host, identity_file, certificate_file, proxy_command, log_file
193
+ t = Tunnel(
194
+ remote_host=host,
195
+ username=user,
196
+ password=password,
197
+ port=port,
198
+ identity_file=id_file,
199
+ certificate_file=certificate,
200
+ proxy_command=proxy,
201
+ ssh_config_file=cfg,
75
202
  )
76
-
77
203
  if ctx:
78
204
  await ctx.report_progress(progress=0, total=100)
79
- logger.debug("Reported initial progress: 0/100")
80
-
81
- tunnel.connect()
82
- out, err = tunnel.run_command(command)
83
-
205
+ logger.debug("Progress: 0/100")
206
+ t.connect()
207
+ out, error = t.run_command(cmd)
84
208
  if ctx:
85
209
  await ctx.report_progress(progress=100, total=100)
86
- logger.debug("Reported final progress: 100/100")
87
-
88
- logger.debug(f"Command output: {out}, error: {err}")
89
- return f"Output:\n{out}\nError:\n{err}"
210
+ logger.debug("Progress: 100/100")
211
+ logger.debug(f"Cmd out: {out}, error: {error}")
212
+ return ResponseBuilder.build(
213
+ 200,
214
+ f"Cmd '{cmd}' done on {host}",
215
+ {"host": host, "cmd": cmd},
216
+ error,
217
+ stdout=out,
218
+ files=[],
219
+ locations=[],
220
+ errors=[],
221
+ )
90
222
  except Exception as e:
91
- logger.error(f"Failed to run command: {str(e)}")
92
- raise RuntimeError(f"Failed to run command: {str(e)}")
223
+ logger.error(f"Cmd fail: {e}")
224
+ return ResponseBuilder.build(
225
+ 500, f"Cmd fail: {e}", {"host": host, "cmd": cmd}, str(e)
226
+ )
93
227
  finally:
94
- if "tunnel" in locals():
95
- tunnel.close()
228
+ if "t" in locals():
229
+ t.close()
96
230
 
97
231
 
98
232
  @mcp.tool(
99
233
  annotations={
100
- "title": "Upload File",
234
+ "title": "Send File from Remote Host",
101
235
  "readOnlyHint": False,
102
236
  "destructiveHint": True,
103
237
  "idempotentHint": False,
104
- "openWorldHint": False,
105
238
  },
106
239
  tags={"remote_access"},
107
240
  )
108
- async def upload_file(
109
- remote_host: str = Field(
110
- description="The remote host to connect to.",
111
- default=os.environ.get("TUNNEL_REMOTE_HOST", None),
112
- ),
113
- remote_port: str = Field(
114
- description="The remote host's port to connect to.",
115
- default=os.environ.get("TUNNEL_REMOTE_PORT", None),
116
- ),
117
- local_path: str = Field(description="Local file path to upload.", default=None),
118
- remote_path: str = Field(description="Remote destination path.", default=None),
119
- identity_file: Optional[str] = Field(
120
- description="Path to the private key file.",
241
+ async def send_file_to_remote_host(
242
+ host: str = Field(
243
+ description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
244
+ ),
245
+ user: Optional[str] = Field(
246
+ description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
247
+ ),
248
+ password: Optional[str] = Field(
249
+ description="Password.", default=os.environ.get("TUNNEL_PASSWORD", None)
250
+ ),
251
+ port: int = Field(
252
+ description="Port.",
253
+ default=to_integer(os.environ.get("TUNNEL_REMOTE_PORT", "22")),
254
+ ),
255
+ lpath: str = Field(description="Local file path.", default=None),
256
+ rpath: str = Field(description="Remote path.", default=None),
257
+ id_file: Optional[str] = Field(
258
+ description="Private key path.",
121
259
  default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
122
260
  ),
123
- certificate_file: Optional[str] = Field(
124
- description="Path to the certificate file (for Teleport).",
261
+ certificate: Optional[str] = Field(
262
+ description="Teleport certificate.",
125
263
  default=os.environ.get("TUNNEL_CERTIFICATE", None),
126
264
  ),
127
- proxy_command: Optional[str] = Field(
128
- description="Proxy command (for Teleport).",
265
+ proxy: Optional[str] = Field(
266
+ description="Teleport proxy.",
129
267
  default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
130
268
  ),
131
- log_file: Optional[str] = Field(
132
- description="Path to log file for this operation.",
133
- default=os.environ.get("TUNNEL_LOG_FILE", None),
269
+ cfg: str = Field(
270
+ description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
134
271
  ),
135
- ctx: Context = Field(
136
- description="MCP context for progress reporting.", default=None
272
+ log: Optional[str] = Field(
273
+ description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
137
274
  ),
138
- ) -> str:
139
- """Uploads a file to a remote host via SSH or Teleport."""
275
+ ctx: Context = Field(description="MCP context.", default=None),
276
+ ) -> Dict:
277
+ """Upload file to remote host. Expected return object type: dict"""
140
278
  logger = logging.getLogger("TunnelServer")
279
+ logger.debug(f"Upload: host={host}, local={lpath}, remote={rpath}")
280
+ lpath = os.path.abspath(os.path.expanduser(lpath)) # Normalize to absolute
281
+ rpath = os.path.expanduser(rpath) # Handle ~ on remote
141
282
  logger.debug(
142
- f"Starting upload_file for host: {remote_host}, local: {local_path}, remote: {remote_path}"
283
+ f"Normalized: lpath={lpath} (exists={os.path.exists(lpath)}, isfile={os.path.isfile(lpath)}), rpath={rpath}, CWD={os.getcwd()}"
143
284
  )
144
285
 
145
- if not remote_host or not local_path or not remote_path:
146
- raise ValueError("remote_host, local_path, and remote_path must be provided.")
286
+ if error := setup_logging(log, logger):
287
+ return error
288
+ logger.debug(f"Upload: host={host}, local={lpath}, remote={rpath}")
289
+ if not host or not lpath or not rpath:
290
+ logger.error("Need host, lpath, rpath")
291
+ return ResponseBuilder.build(
292
+ 400,
293
+ "Need host, lpath, rpath",
294
+ {"host": host, "lpath": lpath, "rpath": rpath},
295
+ errors=["Need host, lpath, rpath"],
296
+ )
297
+ if not os.path.exists(lpath) or not os.path.isfile(lpath):
298
+ logger.error(
299
+ f"Invalid file: {lpath} (exists={os.path.exists(lpath)}, isfile={os.path.isfile(lpath)})"
300
+ )
301
+ return ResponseBuilder.build(
302
+ 400,
303
+ f"Invalid file: {lpath}",
304
+ {"host": host, "lpath": lpath, "rpath": rpath},
305
+ errors=[f"Invalid file: {lpath}"],
306
+ )
307
+ lpath = os.path.abspath(os.path.expanduser(lpath))
308
+ try:
309
+ t = Tunnel(
310
+ remote_host=host,
311
+ username=user,
312
+ password=password,
313
+ port=port,
314
+ identity_file=id_file,
315
+ certificate_file=certificate,
316
+ proxy_command=proxy,
317
+ ssh_config_file=cfg,
318
+ )
319
+ t.connect()
320
+ if ctx:
321
+ await ctx.report_progress(progress=0, total=100)
322
+ logger.debug("Progress: 0/100")
323
+ sftp = t.ssh_client.open_sftp()
324
+ transferred = 0
147
325
 
148
- if not os.path.exists(local_path):
149
- raise ValueError(f"Local file does not exist: {local_path}")
326
+ def progress_callback(transf, total):
327
+ nonlocal transferred
328
+ transferred = transf
329
+ if ctx:
330
+ asyncio.ensure_future(ctx.report_progress(progress=transf, total=total))
150
331
 
151
- try:
152
- tunnel = Tunnel(
153
- remote_host, identity_file, certificate_file, proxy_command, log_file
332
+ sftp.put(lpath, rpath, callback=progress_callback)
333
+ sftp.close()
334
+ logger.debug(f"Uploaded: {lpath} -> {rpath}")
335
+ return ResponseBuilder.build(
336
+ 200,
337
+ f"Uploaded to {rpath}",
338
+ {"host": host, "lpath": lpath, "rpath": rpath},
339
+ files=[lpath],
340
+ locations=[rpath],
341
+ errors=[],
154
342
  )
155
- tunnel.connect()
343
+ except Exception as e:
344
+ logger.error(f"Unexpected error during file transfer: {str(e)}")
345
+ return ResponseBuilder.build(
346
+ 500,
347
+ f"Upload fail: {str(e)}",
348
+ {"host": host, "lpath": lpath, "rpath": rpath},
349
+ str(e),
350
+ errors=[f"Unexpected error: {str(e)}"],
351
+ )
352
+ finally:
353
+ if "t" in locals():
354
+ t.close()
156
355
 
356
+
357
+ @mcp.tool(
358
+ annotations={
359
+ "title": "Receive File from Remote Host",
360
+ "readOnlyHint": False,
361
+ "destructiveHint": False,
362
+ "idempotentHint": True,
363
+ },
364
+ tags={"remote_access"},
365
+ )
366
+ async def receive_file_from_remote_host(
367
+ host: str = Field(
368
+ description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
369
+ ),
370
+ user: Optional[str] = Field(
371
+ description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
372
+ ),
373
+ password: Optional[str] = Field(
374
+ description="Password.", default=os.environ.get("TUNNEL_PASSWORD", None)
375
+ ),
376
+ port: int = Field(
377
+ description="Port.",
378
+ default=to_integer(os.environ.get("TUNNEL_REMOTE_PORT", "22")),
379
+ ),
380
+ rpath: str = Field(description="Remote file path.", default=None),
381
+ lpath: str = Field(description="Local file path.", default=None),
382
+ id_file: Optional[str] = Field(
383
+ description="Private key path.",
384
+ default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
385
+ ),
386
+ certificate: Optional[str] = Field(
387
+ description="Teleport certificate.",
388
+ default=os.environ.get("TUNNEL_CERTIFICATE", None),
389
+ ),
390
+ proxy: Optional[str] = Field(
391
+ description="Teleport proxy.",
392
+ default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
393
+ ),
394
+ cfg: str = Field(
395
+ description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
396
+ ),
397
+ log: Optional[str] = Field(
398
+ description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
399
+ ),
400
+ ctx: Context = Field(description="MCP context.", default=None),
401
+ ) -> Dict:
402
+ """Download file from remote host. Expected return object type: dict"""
403
+ logger = logging.getLogger("TunnelServer")
404
+ lpath = os.path.abspath(os.path.expanduser(lpath))
405
+ if error := setup_logging(log, logger):
406
+ return error
407
+ logger.debug(f"Download: host={host}, remote={rpath}, local={lpath}")
408
+ if not host or not rpath or not lpath:
409
+ logger.error("Need host, rpath, lpath")
410
+ return ResponseBuilder.build(
411
+ 400,
412
+ "Need host, rpath, lpath",
413
+ {"host": host, "rpath": rpath, "lpath": lpath},
414
+ errors=["Need host, rpath, lpath"],
415
+ )
416
+ try:
417
+ t = Tunnel(
418
+ remote_host=host,
419
+ username=user,
420
+ password=password,
421
+ port=port,
422
+ identity_file=id_file,
423
+ certificate_file=certificate,
424
+ proxy_command=proxy,
425
+ ssh_config_file=cfg,
426
+ )
427
+ t.connect()
157
428
  if ctx:
158
429
  await ctx.report_progress(progress=0, total=100)
159
- logger.debug("Reported initial progress: 0/100")
160
-
161
- sftp = tunnel.ssh_client.open_sftp()
162
- file_size = os.path.getsize(local_path)
430
+ logger.debug("Progress: 0/100")
431
+ sftp = t.ssh_client.open_sftp()
432
+ sftp.stat(rpath)
163
433
  transferred = 0
164
434
 
165
435
  def progress_callback(transf, total):
166
436
  nonlocal transferred
167
437
  transferred = transf
438
+ if ctx:
439
+ asyncio.ensure_future(ctx.report_progress(progress=transf, total=total))
168
440
 
169
- sftp.put(local_path, remote_path, callback=progress_callback)
170
-
441
+ sftp.get(rpath, lpath, callback=progress_callback)
171
442
  if ctx:
172
443
  await ctx.report_progress(progress=100, total=100)
173
- logger.debug("Reported final progress: 100/100")
174
-
444
+ logger.debug("Progress: 100/100")
175
445
  sftp.close()
176
- logger.debug(f"File uploaded: {local_path} -> {remote_path}")
177
- return f"File uploaded successfully to {remote_path}"
446
+ logger.debug(f"Downloaded: {rpath} -> {lpath}")
447
+ return ResponseBuilder.build(
448
+ 200,
449
+ f"Downloaded to {lpath}",
450
+ {"host": host, "rpath": rpath, "lpath": lpath},
451
+ files=[rpath],
452
+ locations=[lpath],
453
+ errors=[],
454
+ )
178
455
  except Exception as e:
179
- logger.error(f"Failed to upload file: {str(e)}")
180
- raise RuntimeError(f"Failed to upload file: {str(e)}")
456
+ logger.error(f"Download fail: {e}")
457
+ return ResponseBuilder.build(
458
+ 500,
459
+ f"Download fail: {e}",
460
+ {"host": host, "rpath": rpath, "lpath": lpath},
461
+ str(e),
462
+ )
181
463
  finally:
182
- if "tunnel" in locals():
183
- tunnel.close()
464
+ if "t" in locals():
465
+ t.close()
184
466
 
185
467
 
186
468
  @mcp.tool(
187
469
  annotations={
188
- "title": "Download File",
189
- "readOnlyHint": False,
470
+ "title": "Check SSH Server",
471
+ "readOnlyHint": True,
190
472
  "destructiveHint": False,
191
473
  "idempotentHint": True,
192
- "openWorldHint": False,
193
474
  },
194
475
  tags={"remote_access"},
195
476
  )
196
- async def download_file(
197
- remote_host: str = Field(
198
- description="The remote host to connect to.",
199
- default=os.environ.get("TUNNEL_REMOTE_HOST", None),
200
- ),
201
- remote_port: str = Field(
202
- description="The remote host's port to connect to.",
203
- default=os.environ.get("TUNNEL_REMOTE_PORT", None),
204
- ),
205
- remote_path: str = Field(description="Remote file path to download.", default=None),
206
- local_path: str = Field(description="Local destination path.", default=None),
207
- identity_file: Optional[str] = Field(
208
- description="Path to the private key file.",
477
+ async def check_ssh_server(
478
+ host: str = Field(
479
+ description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
480
+ ),
481
+ user: Optional[str] = Field(
482
+ description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
483
+ ),
484
+ password: Optional[str] = Field(
485
+ description="Password.", default=os.environ.get("TUNNEL_PASSWORD", None)
486
+ ),
487
+ port: int = Field(
488
+ description="Port.",
489
+ default=to_integer(os.environ.get("TUNNEL_REMOTE_PORT", "22")),
490
+ ),
491
+ id_file: Optional[str] = Field(
492
+ description="Private key path.",
209
493
  default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
210
494
  ),
211
- certificate_file: Optional[str] = Field(
212
- description="Path to the certificate file (for Teleport).",
495
+ certificate: Optional[str] = Field(
496
+ description="Teleport certificate.",
213
497
  default=os.environ.get("TUNNEL_CERTIFICATE", None),
214
498
  ),
215
- proxy_command: Optional[str] = Field(
216
- description="Proxy command (for Teleport).",
499
+ proxy: Optional[str] = Field(
500
+ description="Teleport proxy.",
217
501
  default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
218
502
  ),
219
- log_file: Optional[str] = Field(
220
- description="Path to log file for this operation.",
221
- default=os.environ.get("TUNNEL_LOG_FILE", None),
503
+ cfg: str = Field(
504
+ description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
222
505
  ),
223
- ctx: Context = Field(
224
- description="MCP context for progress reporting.", default=None
506
+ log: Optional[str] = Field(
507
+ description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
225
508
  ),
226
- ) -> str:
227
- """Downloads a file from a remote host via SSH or Teleport."""
509
+ ctx: Context = Field(description="MCP context.", default=None),
510
+ ) -> Dict:
511
+ """Check SSH server status. Expected return object type: dict"""
228
512
  logger = logging.getLogger("TunnelServer")
229
- logger.debug(
230
- f"Starting download_file for host: {remote_host}, remote: {remote_path}, local: {local_path}"
231
- )
513
+ if error := setup_logging(log, logger):
514
+ return error
515
+ logger.debug(f"Check SSH: host={host}")
516
+ if not host:
517
+ logger.error("Need host")
518
+ return ResponseBuilder.build(
519
+ 400, "Need host", {"host": host}, errors=["Need host"]
520
+ )
521
+ try:
522
+ t = Tunnel(
523
+ remote_host=host,
524
+ username=user,
525
+ password=password,
526
+ port=port,
527
+ identity_file=id_file,
528
+ certificate_file=certificate,
529
+ proxy_command=proxy,
530
+ ssh_config_file=cfg,
531
+ )
532
+ if ctx:
533
+ await ctx.report_progress(progress=0, total=100)
534
+ logger.debug("Progress: 0/100")
535
+ success, msg = t.check_ssh_server()
536
+ if ctx:
537
+ await ctx.report_progress(progress=100, total=100)
538
+ logger.debug("Progress: 100/100")
539
+ logger.debug(f"SSH check: {msg}")
540
+ return ResponseBuilder.build(
541
+ 200 if success else 400,
542
+ f"SSH check: {msg}",
543
+ {"host": host, "success": success},
544
+ files=[],
545
+ locations=[],
546
+ errors=[] if success else [msg],
547
+ )
548
+ except Exception as e:
549
+ logger.error(f"Check fail: {e}")
550
+ return ResponseBuilder.build(500, f"Check fail: {e}", {"host": host}, str(e))
551
+ finally:
552
+ if "t" in locals():
553
+ t.close()
232
554
 
233
- if not remote_host or not remote_path or not local_path:
234
- raise ValueError("remote_host, remote_path, and local_path must be provided.")
235
555
 
556
+ @mcp.tool(
557
+ annotations={
558
+ "title": "Test Key Authentication",
559
+ "readOnlyHint": True,
560
+ "destructiveHint": False,
561
+ "idempotentHint": True,
562
+ },
563
+ tags={"remote_access"},
564
+ )
565
+ async def test_key_auth(
566
+ host: str = Field(
567
+ description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
568
+ ),
569
+ user: Optional[str] = Field(
570
+ description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
571
+ ),
572
+ key: str = Field(
573
+ description="Private key path.",
574
+ default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
575
+ ),
576
+ port: int = Field(
577
+ description="Port.",
578
+ default=to_integer(os.environ.get("TUNNEL_REMOTE_PORT", "22")),
579
+ ),
580
+ cfg: str = Field(
581
+ description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
582
+ ),
583
+ log: Optional[str] = Field(
584
+ description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
585
+ ),
586
+ ctx: Context = Field(description="MCP context.", default=None),
587
+ ) -> Dict:
588
+ """Test key-based auth. Expected return object type: dict"""
589
+ logger = logging.getLogger("TunnelServer")
590
+ if error := setup_logging(log, logger):
591
+ return error
592
+ logger.debug(f"Test key: host={host}, key={key}")
593
+ if not host or not key:
594
+ logger.error("Need host, key")
595
+ return ResponseBuilder.build(
596
+ 400, "Need host, key", {"host": host, "key": key}, errors=["Need host, key"]
597
+ )
236
598
  try:
237
- tunnel = Tunnel(
238
- remote_host, identity_file, certificate_file, proxy_command, log_file
599
+ t = Tunnel(remote_host=host, username=user, port=port, ssh_config_file=cfg)
600
+ if ctx:
601
+ await ctx.report_progress(progress=0, total=100)
602
+ logger.debug("Progress: 0/100")
603
+ success, msg = t.test_key_auth(key)
604
+ if ctx:
605
+ await ctx.report_progress(progress=100, total=100)
606
+ logger.debug("Progress: 100/100")
607
+ logger.debug(f"Key test: {msg}")
608
+ return ResponseBuilder.build(
609
+ 200 if success else 400,
610
+ f"Key test: {msg}",
611
+ {"host": host, "key": key, "success": success},
612
+ files=[],
613
+ locations=[],
614
+ errors=[] if success else [msg],
615
+ )
616
+ except Exception as e:
617
+ logger.error(f"Key test fail: {e}")
618
+ return ResponseBuilder.build(
619
+ 500, f"Key test fail: {e}", {"host": host, "key": key}, str(e)
239
620
  )
240
- tunnel.connect()
241
621
 
622
+
623
+ @mcp.tool(
624
+ annotations={
625
+ "title": "Setup Passwordless SSH",
626
+ "readOnlyHint": False,
627
+ "destructiveHint": True,
628
+ "idempotentHint": False,
629
+ },
630
+ tags={"remote_access"},
631
+ )
632
+ async def setup_passwordless_ssh(
633
+ host: str = Field(
634
+ description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
635
+ ),
636
+ user: Optional[str] = Field(
637
+ description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
638
+ ),
639
+ password: Optional[str] = Field(
640
+ description="Password.", default=os.environ.get("TUNNEL_PASSWORD", None)
641
+ ),
642
+ port: int = Field(
643
+ description="Port.",
644
+ default=to_integer(os.environ.get("TUNNEL_REMOTE_PORT", "22")),
645
+ ),
646
+ key: str = Field(
647
+ description="Private key path.", default=os.path.expanduser("~/.ssh/id_rsa")
648
+ ),
649
+ key_type: str = Field(
650
+ description="Key type to generate (rsa or ed25519).", default="ed25519"
651
+ ),
652
+ cfg: str = Field(
653
+ description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
654
+ ),
655
+ log: Optional[str] = Field(
656
+ description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
657
+ ),
658
+ ctx: Context = Field(description="MCP context.", default=None),
659
+ ) -> Dict:
660
+ """Setup passwordless SSH. Expected return object type: dict"""
661
+ logger = logging.getLogger("TunnelServer")
662
+ if error := setup_logging(log, logger):
663
+ return error
664
+ logger.debug(f"Setup SSH: host={host}, key={key}, key_type={key_type}")
665
+ if not host or not password:
666
+ logger.error("Need host, password")
667
+ return ResponseBuilder.build(
668
+ 400,
669
+ "Need host, password",
670
+ {"host": host, "key": key, "key_type": key_type},
671
+ errors=["Need host, password"],
672
+ )
673
+ if key_type not in ["rsa", "ed25519"]:
674
+ logger.error(f"Invalid key_type: {key_type}")
675
+ return ResponseBuilder.build(
676
+ 400,
677
+ f"Invalid key_type: {key_type}",
678
+ {"host": host, "key": key, "key_type": key_type},
679
+ errors=["key_type must be 'rsa' or 'ed25519'"],
680
+ )
681
+ try:
682
+ t = Tunnel(
683
+ remote_host=host,
684
+ username=user,
685
+ password=password,
686
+ port=port,
687
+ ssh_config_file=cfg,
688
+ )
242
689
  if ctx:
243
690
  await ctx.report_progress(progress=0, total=100)
244
- logger.debug("Reported initial progress: 0/100")
691
+ logger.debug("Progress: 0/100")
692
+ key = os.path.expanduser(key)
693
+ pub_key = key + ".pub"
694
+ if not os.path.exists(pub_key):
695
+ if key_type == "rsa":
696
+ os.system(f"ssh-keygen -t rsa -b 4096 -f {key} -N ''")
697
+ else: # ed25519
698
+ os.system(f"ssh-keygen -t ed25519 -f {key} -N ''")
699
+ logger.info(f"Generated {key_type} key: {key}, {pub_key}")
700
+ t.setup_passwordless_ssh(local_key_path=key, key_type=key_type)
701
+ if ctx:
702
+ await ctx.report_progress(progress=100, total=100)
703
+ logger.debug("Progress: 100/100")
704
+ logger.debug(f"SSH setup for {user}@{host}")
705
+ return ResponseBuilder.build(
706
+ 200,
707
+ f"SSH setup for {user}@{host}",
708
+ {"host": host, "key": key, "user": user, "key_type": key_type},
709
+ files=[pub_key],
710
+ locations=[f"~/.ssh/authorized_keys on {host}"],
711
+ errors=[],
712
+ )
713
+ except Exception as e:
714
+ logger.error(f"SSH setup fail: {e}")
715
+ return ResponseBuilder.build(
716
+ 500,
717
+ f"SSH setup fail: {e}",
718
+ {"host": host, "key": key, "key_type": key_type},
719
+ str(e),
720
+ )
721
+ finally:
722
+ if "t" in locals():
723
+ t.close()
245
724
 
246
- sftp = tunnel.ssh_client.open_sftp()
247
- remote_attr = sftp.stat(remote_path)
248
- file_size = remote_attr.st_size
249
- transferred = 0
250
725
 
251
- def progress_callback(transf, total):
252
- nonlocal transferred
253
- transferred = transf
726
+ @mcp.tool(
727
+ annotations={
728
+ "title": "Copy SSH Config",
729
+ "readOnlyHint": False,
730
+ "destructiveHint": True,
731
+ "idempotentHint": False,
732
+ },
733
+ tags={"remote_access"},
734
+ )
735
+ async def copy_ssh_config(
736
+ host: str = Field(
737
+ description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
738
+ ),
739
+ user: Optional[str] = Field(
740
+ description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
741
+ ),
742
+ password: Optional[str] = Field(
743
+ description="Password.", default=os.environ.get("TUNNEL_PASSWORD", None)
744
+ ),
745
+ port: int = Field(
746
+ description="Port.",
747
+ default=to_integer(os.environ.get("TUNNEL_REMOTE_PORT", "22")),
748
+ ),
749
+ lcfg: str = Field(description="Local SSH config.", default=None),
750
+ rcfg: str = Field(
751
+ description="Remote SSH config.", default=os.path.expanduser("~/.ssh/config")
752
+ ),
753
+ id_file: Optional[str] = Field(
754
+ description="Private key path.",
755
+ default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
756
+ ),
757
+ certificate: Optional[str] = Field(
758
+ description="Teleport certificate.",
759
+ default=os.environ.get("TUNNEL_CERTIFICATE", None),
760
+ ),
761
+ proxy: Optional[str] = Field(
762
+ description="Teleport proxy.",
763
+ default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
764
+ ),
765
+ cfg: str = Field(
766
+ description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
767
+ ),
768
+ log: Optional[str] = Field(
769
+ description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
770
+ ),
771
+ ctx: Context = Field(description="MCP context.", default=None),
772
+ ) -> Dict:
773
+ """Copy SSH config to remote host. Expected return object type: dict"""
774
+ logger = logging.getLogger("TunnelServer")
775
+ if error := setup_logging(log, logger):
776
+ return error
777
+ logger.debug(f"Copy cfg: host={host}, local={lcfg}, remote={rcfg}")
778
+ if not host or not lcfg:
779
+ logger.error("Need host, lcfg")
780
+ return ResponseBuilder.build(
781
+ 400,
782
+ "Need host, lcfg",
783
+ {"host": host, "lcfg": lcfg, "rcfg": rcfg},
784
+ errors=["Need host, lcfg"],
785
+ )
786
+ try:
787
+ t = Tunnel(
788
+ remote_host=host,
789
+ username=user,
790
+ password=password,
791
+ port=port,
792
+ identity_file=id_file,
793
+ certificate_file=certificate,
794
+ proxy_command=proxy,
795
+ ssh_config_file=cfg,
796
+ )
797
+ if ctx:
798
+ await ctx.report_progress(progress=0, total=100)
799
+ logger.debug("Progress: 0/100")
800
+ t.copy_ssh_config(lcfg, rcfg)
801
+ if ctx:
802
+ await ctx.report_progress(progress=100, total=100)
803
+ logger.debug("Progress: 100/100")
804
+ logger.debug(f"Copied cfg to {rcfg} on {host}")
805
+ return ResponseBuilder.build(
806
+ 200,
807
+ f"Copied cfg to {rcfg} on {host}",
808
+ {"host": host, "lcfg": lcfg, "rcfg": rcfg},
809
+ files=[lcfg],
810
+ locations=[rcfg],
811
+ errors=[],
812
+ )
813
+ except Exception as e:
814
+ logger.error(f"Copy cfg fail: {e}")
815
+ return ResponseBuilder.build(
816
+ 500,
817
+ f"Copy cfg fail: {e}",
818
+ {"host": host, "lcfg": lcfg, "rcfg": rcfg},
819
+ str(e),
820
+ )
821
+ finally:
822
+ if "t" in locals():
823
+ t.close()
824
+
825
+
826
+ @mcp.tool(
827
+ annotations={
828
+ "title": "Rotate SSH Key",
829
+ "readOnlyHint": False,
830
+ "destructiveHint": True,
831
+ "idempotentHint": False,
832
+ },
833
+ tags={"remote_access"},
834
+ )
835
+ async def rotate_ssh_key(
836
+ host: str = Field(
837
+ description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
838
+ ),
839
+ user: Optional[str] = Field(
840
+ description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
841
+ ),
842
+ password: Optional[str] = Field(
843
+ description="Password.", default=os.environ.get("TUNNEL_PASSWORD", None)
844
+ ),
845
+ port: int = Field(
846
+ description="Port.",
847
+ default=to_integer(os.environ.get("TUNNEL_REMOTE_PORT", "22")),
848
+ ),
849
+ new_key: str = Field(description="New private key path.", default=None),
850
+ key_type: str = Field(
851
+ description="Key type to generate (rsa or ed25519).", default="ed25519"
852
+ ),
853
+ id_file: Optional[str] = Field(
854
+ description="Current key path.",
855
+ default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
856
+ ),
857
+ certificate: Optional[str] = Field(
858
+ description="Teleport certificate.",
859
+ default=os.environ.get("TUNNEL_CERTIFICATE", None),
860
+ ),
861
+ proxy: Optional[str] = Field(
862
+ description="Teleport proxy.",
863
+ default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
864
+ ),
865
+ cfg: str = Field(
866
+ description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
867
+ ),
868
+ log: Optional[str] = Field(
869
+ description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
870
+ ),
871
+ ctx: Context = Field(description="MCP context.", default=None),
872
+ ) -> Dict:
873
+ """Rotate SSH key on remote host. Expected return object type: dict"""
874
+ logger = logging.getLogger("TunnelServer")
875
+ if error := setup_logging(log, logger):
876
+ return error
877
+ logger.debug(f"Rotate key: host={host}, new_key={new_key}, key_type={key_type}")
878
+ if not host or not new_key:
879
+ logger.error("Need host, new_key")
880
+ return ResponseBuilder.build(
881
+ 400,
882
+ "Need host, new_key",
883
+ {"host": host, "new_key": new_key, "key_type": key_type},
884
+ errors=["Need host, new_key"],
885
+ )
886
+ if key_type not in ["rsa", "ed25519"]:
887
+ logger.error(f"Invalid key_type: {key_type}")
888
+ return ResponseBuilder.build(
889
+ 400,
890
+ f"Invalid key_type: {key_type}",
891
+ {"host": host, "new_key": new_key, "key_type": key_type},
892
+ errors=["key_type must be 'rsa' or 'ed25519'"],
893
+ )
894
+ try:
895
+ t = Tunnel(
896
+ remote_host=host,
897
+ username=user,
898
+ password=password,
899
+ port=port,
900
+ identity_file=id_file,
901
+ certificate_file=certificate,
902
+ proxy_command=proxy,
903
+ ssh_config_file=cfg,
904
+ )
905
+ if ctx:
906
+ await ctx.report_progress(progress=0, total=100)
907
+ logger.debug("Progress: 0/100")
908
+ new_key = os.path.expanduser(new_key)
909
+ new_public_key = new_key + ".pub"
910
+ if not os.path.exists(new_key):
911
+ if key_type == "rsa":
912
+ os.system(f"ssh-keygen -t rsa -b 4096 -f {new_key} -N ''")
913
+ else: # ed25519
914
+ os.system(f"ssh-keygen -t ed25519 -f {new_key} -N ''")
915
+ logger.info(f"Generated {key_type} key: {new_key}")
916
+ t.rotate_ssh_key(new_key, key_type=key_type)
917
+ if ctx:
918
+ await ctx.report_progress(progress=100, total=100)
919
+ logger.debug("Progress: 100/100")
920
+ logger.debug(f"Rotated {key_type} key to {new_key} on {host}")
921
+ return ResponseBuilder.build(
922
+ 200,
923
+ f"Rotated {key_type} key to {new_key} on {host}",
924
+ {
925
+ "host": host,
926
+ "new_key": new_key,
927
+ "old_key": id_file,
928
+ "key_type": key_type,
929
+ },
930
+ files=[new_public_key],
931
+ locations=[f"~/.ssh/authorized_keys on {host}"],
932
+ errors=[],
933
+ )
934
+ except Exception as e:
935
+ logger.error(f"Rotate fail: {e}")
936
+ return ResponseBuilder.build(
937
+ 500,
938
+ f"Rotate fail: {e}",
939
+ {"host": host, "new_key": new_key, "key_type": key_type},
940
+ str(e),
941
+ )
942
+ finally:
943
+ if "t" in locals():
944
+ t.close()
254
945
 
255
- sftp.get(remote_path, local_path, callback=progress_callback)
256
946
 
947
+ @mcp.tool(
948
+ annotations={
949
+ "title": "Remove Host Key",
950
+ "readOnlyHint": False,
951
+ "destructiveHint": True,
952
+ "idempotentHint": True,
953
+ },
954
+ tags={"remote_access"},
955
+ )
956
+ async def remove_host_key(
957
+ host: str = Field(
958
+ description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
959
+ ),
960
+ known_hosts: str = Field(
961
+ description="Known hosts path.",
962
+ default=os.path.expanduser("~/.ssh/known_hosts"),
963
+ ),
964
+ log: Optional[str] = Field(
965
+ description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
966
+ ),
967
+ ctx: Context = Field(description="MCP context.", default=None),
968
+ ) -> Dict:
969
+ """Remove host key from known_hosts. Expected return object type: dict"""
970
+ logger = logging.getLogger("TunnelServer")
971
+ if error := setup_logging(log, logger):
972
+ return error
973
+ logger.debug(f"Remove key: host={host}, known_hosts={known_hosts}")
974
+ if not host:
975
+ logger.error("Need host")
976
+ return ResponseBuilder.build(
977
+ 400,
978
+ "Need host",
979
+ {"host": host, "known_hosts": known_hosts},
980
+ errors=["Need host"],
981
+ )
982
+ try:
983
+ t = Tunnel(remote_host=host)
984
+ if ctx:
985
+ await ctx.report_progress(progress=0, total=100)
986
+ logger.debug("Progress: 0/100")
987
+ known_hosts = os.path.expanduser(known_hosts)
988
+ msg = t.remove_host_key(known_hosts_path=known_hosts)
257
989
  if ctx:
258
990
  await ctx.report_progress(progress=100, total=100)
259
- logger.debug("Reported final progress: 100/100")
991
+ logger.debug("Progress: 100/100")
992
+ logger.debug(f"Remove result: {msg}")
993
+ return ResponseBuilder.build(
994
+ 200 if "Removed" in msg else 400,
995
+ msg,
996
+ {"host": host, "known_hosts": known_hosts},
997
+ files=[],
998
+ locations=[],
999
+ errors=[] if "Removed" in msg else [msg],
1000
+ )
1001
+ except Exception as e:
1002
+ logger.error(f"Remove fail: {e}")
1003
+ return ResponseBuilder.build(
1004
+ 500, f"Remove fail: {e}", {"host": host, "known_hosts": known_hosts}, str(e)
1005
+ )
260
1006
 
261
- sftp.close()
262
- logger.debug(f"File downloaded: {remote_path} -> {local_path}")
263
- return f"File downloaded successfully to {local_path}"
1007
+
1008
+ @mcp.tool(
1009
+ annotations={
1010
+ "title": "Setup Passwordless SSH for All",
1011
+ "readOnlyHint": False,
1012
+ "destructiveHint": True,
1013
+ "idempotentHint": False,
1014
+ },
1015
+ tags={"remote_access"},
1016
+ )
1017
+ async def configure_key_auth_on_inventory(
1018
+ inventory: str = Field(
1019
+ description="YAML inventory path.",
1020
+ default=os.environ.get("TUNNEL_INVENTORY", None),
1021
+ ),
1022
+ key: str = Field(
1023
+ description="Shared key path.",
1024
+ default=os.environ.get(
1025
+ "TUNNEL_IDENTITY_FILE", os.path.expanduser("~/.ssh/id_shared")
1026
+ ),
1027
+ ),
1028
+ key_type: str = Field(
1029
+ description="Key type to generate (rsa or ed25519).", default="ed25519"
1030
+ ),
1031
+ group: str = Field(
1032
+ description="Target group.",
1033
+ default=os.environ.get("TUNNEL_INVENTORY_GROUP", "all"),
1034
+ ),
1035
+ parallel: bool = Field(
1036
+ description="Run parallel.",
1037
+ default=to_boolean(os.environ.get("TUNNEL_PARALLEL", False)),
1038
+ ),
1039
+ max_threads: int = Field(
1040
+ description="Max threads.",
1041
+ default=to_integer(os.environ.get("TUNNEL_MAX_THREADS", "6")),
1042
+ ),
1043
+ log: Optional[str] = Field(description="Log file.", default=None),
1044
+ ctx: Context = Field(description="MCP context.", default=None),
1045
+ ) -> Dict:
1046
+ """Setup passwordless SSH for all hosts in group. Expected return object type: dict"""
1047
+ logger = logging.getLogger("TunnelServer")
1048
+ if error := setup_logging(log, logger):
1049
+ return error
1050
+ logger.debug(f"Setup SSH all: inv={inventory}, group={group}, key_type={key_type}")
1051
+ if not inventory:
1052
+ logger.error("Need inventory")
1053
+ return ResponseBuilder.build(
1054
+ 400,
1055
+ "Need inventory",
1056
+ {"inventory": inventory, "group": group, "key_type": key_type},
1057
+ errors=["Need inventory"],
1058
+ )
1059
+ if key_type not in ["rsa", "ed25519"]:
1060
+ logger.error(f"Invalid key_type: {key_type}")
1061
+ return ResponseBuilder.build(
1062
+ 400,
1063
+ f"Invalid key_type: {key_type}",
1064
+ {"inventory": inventory, "group": group, "key_type": key_type},
1065
+ errors=["key_type must be 'rsa' or 'ed25519'"],
1066
+ )
1067
+ try:
1068
+ key = os.path.expanduser(key)
1069
+ pub_key = key + ".pub"
1070
+ if not os.path.exists(key):
1071
+ if key_type == "rsa":
1072
+ os.system(f"ssh-keygen -t rsa -b 4096 -f {key} -N ''")
1073
+ else: # ed25519
1074
+ os.system(f"ssh-keygen -t ed25519 -f {key} -N ''")
1075
+ logger.info(f"Generated {key_type} key: {key}, {pub_key}")
1076
+ with open(pub_key, "r") as f:
1077
+ pub = f.read().strip()
1078
+ hosts, error = load_inventory(inventory, group, logger)
1079
+ if error:
1080
+ return error
1081
+ total = len(hosts)
1082
+ if ctx:
1083
+ await ctx.report_progress(progress=0, total=total)
1084
+ logger.debug(f"Progress: 0/{total}")
1085
+
1086
+ async def setup_host(h: Dict, ctx: Context) -> Dict:
1087
+ host, user, password = h["hostname"], h["username"], h["password"]
1088
+ kpath = h.get("key_path", key)
1089
+ logger.info(f"Setup {user}@{host}")
1090
+ try:
1091
+ t = Tunnel(remote_host=host, username=user, password=password)
1092
+ t.remove_host_key()
1093
+ t.setup_passwordless_ssh(local_key_path=kpath, key_type=key_type)
1094
+ t.connect()
1095
+ t.run_command(f"echo '{pub}' >> ~/.ssh/authorized_keys")
1096
+ t.run_command("chmod 600 ~/.ssh/authorized_keys")
1097
+ logger.info(f"Added {key_type} key to {user}@{host}")
1098
+ res, msg = t.test_key_auth(kpath)
1099
+ return {
1100
+ "hostname": host,
1101
+ "status": "success",
1102
+ "message": f"SSH setup for {user}@{host} with {key_type} key",
1103
+ "errors": [] if res else [msg],
1104
+ }
1105
+ except Exception as e:
1106
+ logger.error(f"Setup fail {user}@{host}: {e}")
1107
+ return {
1108
+ "hostname": host,
1109
+ "status": "failed",
1110
+ "message": f"Setup fail: {e}",
1111
+ "errors": [str(e)],
1112
+ }
1113
+ finally:
1114
+ if "t" in locals():
1115
+ t.close()
1116
+
1117
+ results, files, locations, errors = [], [], [], []
1118
+ if parallel:
1119
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as ex:
1120
+ futures = [
1121
+ ex.submit(lambda h: asyncio.run(setup_host(h, ctx)), h)
1122
+ for h in hosts
1123
+ ]
1124
+ for i, f in enumerate(concurrent.futures.as_completed(futures), 1):
1125
+ try:
1126
+ r = f.result()
1127
+ results.append(r)
1128
+ if r["status"] == "success":
1129
+ files.append(pub_key)
1130
+ locations.append(
1131
+ f"~/.ssh/authorized_keys on {r['hostname']}"
1132
+ )
1133
+ else:
1134
+ errors.extend(r["errors"])
1135
+ if ctx:
1136
+ await ctx.report_progress(progress=i, total=total)
1137
+ logger.debug(f"Progress: {i}/{total}")
1138
+ except Exception as e:
1139
+ logger.error(f"Parallel error: {e}")
1140
+ results.append(
1141
+ {
1142
+ "hostname": "unknown",
1143
+ "status": "failed",
1144
+ "message": f"Parallel error: {e}",
1145
+ "errors": [str(e)],
1146
+ }
1147
+ )
1148
+ errors.append(str(e))
1149
+ else:
1150
+ for i, h in enumerate(hosts, 1):
1151
+ r = await setup_host(h, ctx)
1152
+ results.append(r)
1153
+ if r["status"] == "success":
1154
+ files.append(pub_key)
1155
+ locations.append(f"~/.ssh/authorized_keys on {r['hostname']}")
1156
+ else:
1157
+ errors.extend(r["errors"])
1158
+ if ctx:
1159
+ await ctx.report_progress(progress=i, total=total)
1160
+ logger.debug(f"Progress: {i}/{total}")
1161
+ logger.debug(f"Done SSH setup for {group}")
1162
+ msg = (
1163
+ f"SSH setup done for {group}"
1164
+ if not errors
1165
+ else f"SSH setup failed for some in {group}"
1166
+ )
1167
+ return ResponseBuilder.build(
1168
+ 200 if not errors else 500,
1169
+ msg,
1170
+ {
1171
+ "inventory": inventory,
1172
+ "group": group,
1173
+ "key_type": key_type,
1174
+ "host_results": results,
1175
+ },
1176
+ "; ".join(errors),
1177
+ files,
1178
+ locations,
1179
+ errors,
1180
+ )
264
1181
  except Exception as e:
265
- logger.error(f"Failed to download file: {str(e)}")
266
- raise RuntimeError(f"Failed to download file: {str(e)}")
267
- finally:
268
- if "tunnel" in locals():
269
- tunnel.close()
1182
+ logger.error(f"Setup all fail: {e}")
1183
+ return ResponseBuilder.build(
1184
+ 500,
1185
+ f"Setup all fail: {e}",
1186
+ {"inventory": inventory, "group": group, "key_type": key_type},
1187
+ str(e),
1188
+ )
1189
+
1190
+
1191
+ @mcp.tool(
1192
+ annotations={
1193
+ "title": "Run Command on All Hosts",
1194
+ "readOnlyHint": True,
1195
+ "destructiveHint": True,
1196
+ "idempotentHint": False,
1197
+ },
1198
+ tags={"remote_access"},
1199
+ )
1200
+ async def run_command_on_inventory(
1201
+ inventory: str = Field(
1202
+ description="YAML inventory path.",
1203
+ default=os.environ.get("TUNNEL_INVENTORY", None),
1204
+ ),
1205
+ cmd: str = Field(description="Shell command.", default=None),
1206
+ group: str = Field(
1207
+ description="Target group.",
1208
+ default=os.environ.get("TUNNEL_INVENTORY_GROUP", "all"),
1209
+ ),
1210
+ parallel: bool = Field(
1211
+ description="Run parallel.",
1212
+ default=to_boolean(os.environ.get("TUNNEL_PARALLEL", False)),
1213
+ ),
1214
+ max_threads: int = Field(
1215
+ description="Max threads.",
1216
+ default=to_integer(os.environ.get("TUNNEL_MAX_THREADS", "6")),
1217
+ ),
1218
+ log: Optional[str] = Field(description="Log file.", default=None),
1219
+ ctx: Context = Field(description="MCP context.", default=None),
1220
+ ) -> Dict:
1221
+ """Run command on all hosts in group. Expected return object type: dict"""
1222
+ logger = logging.getLogger("TunnelServer")
1223
+ if error := setup_logging(log, logger):
1224
+ return error
1225
+ logger.debug(f"Run cmd all: inv={inventory}, group={group}, cmd={cmd}")
1226
+ if not inventory or not cmd:
1227
+ logger.error("Need inventory, cmd")
1228
+ return ResponseBuilder.build(
1229
+ 400,
1230
+ "Need inventory, cmd",
1231
+ {"inventory": inventory, "group": group, "cmd": cmd},
1232
+ errors=["Need inventory, cmd"],
1233
+ )
1234
+ try:
1235
+ hosts, error = load_inventory(inventory, group, logger)
1236
+ if error:
1237
+ return error
1238
+ total = len(hosts)
1239
+ if ctx:
1240
+ await ctx.report_progress(progress=0, total=total)
1241
+ logger.debug(f"Progress: 0/{total}")
1242
+
1243
+ async def run_host(h: Dict, ctx: Context) -> Dict:
1244
+ host = h["hostname"]
1245
+ try:
1246
+ t = Tunnel(
1247
+ remote_host=host,
1248
+ username=h["username"],
1249
+ password=h.get("password"),
1250
+ identity_file=h.get("key_path"),
1251
+ )
1252
+ out, error = t.run_command(cmd)
1253
+ logger.info(f"Host {host}: Out: {out}, Err: {error}")
1254
+ return {
1255
+ "hostname": host,
1256
+ "status": "success",
1257
+ "message": f"Cmd '{cmd}' done on {host}",
1258
+ "stdout": out,
1259
+ "stderr": error,
1260
+ "errors": [],
1261
+ }
1262
+ except Exception as e:
1263
+ logger.error(f"Cmd fail {host}: {e}")
1264
+ return {
1265
+ "hostname": host,
1266
+ "status": "failed",
1267
+ "message": f"Cmd fail: {e}",
1268
+ "stdout": "",
1269
+ "stderr": str(e),
1270
+ "errors": [str(e)],
1271
+ }
1272
+ finally:
1273
+ if "t" in locals():
1274
+ t.close()
1275
+
1276
+ results, errors = [], []
1277
+ if parallel:
1278
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as ex:
1279
+ futures = [
1280
+ ex.submit(lambda h: asyncio.run(run_host(h, ctx)), h) for h in hosts
1281
+ ]
1282
+ for i, f in enumerate(concurrent.futures.as_completed(futures), 1):
1283
+ try:
1284
+ r = f.result()
1285
+ results.append(r)
1286
+ errors.extend(r["errors"])
1287
+ if ctx:
1288
+ await ctx.report_progress(progress=i, total=total)
1289
+ logger.debug(f"Progress: {i}/{total}")
1290
+ except Exception as e:
1291
+ logger.error(f"Parallel error: {e}")
1292
+ results.append(
1293
+ {
1294
+ "hostname": "unknown",
1295
+ "status": "failed",
1296
+ "message": f"Parallel error: {e}",
1297
+ "stdout": "",
1298
+ "stderr": str(e),
1299
+ "errors": [str(e)],
1300
+ }
1301
+ )
1302
+ errors.append(str(e))
1303
+ else:
1304
+ for i, h in enumerate(hosts, 1):
1305
+ r = await run_host(h, ctx)
1306
+ results.append(r)
1307
+ errors.extend(r["errors"])
1308
+ if ctx:
1309
+ await ctx.report_progress(progress=i, total=total)
1310
+ logger.debug(f"Progress: {i}/{total}")
1311
+ logger.debug(f"Done cmd for {group}")
1312
+ msg = (
1313
+ f"Cmd '{cmd}' done on {group}"
1314
+ if not errors
1315
+ else f"Cmd '{cmd}' failed for some in {group}"
1316
+ )
1317
+ return ResponseBuilder.build(
1318
+ 200 if not errors else 500,
1319
+ msg,
1320
+ {
1321
+ "inventory": inventory,
1322
+ "group": group,
1323
+ "cmd": cmd,
1324
+ "host_results": results,
1325
+ },
1326
+ "; ".join(errors),
1327
+ [],
1328
+ [],
1329
+ errors,
1330
+ )
1331
+ except Exception as e:
1332
+ logger.error(f"Cmd all fail: {e}")
1333
+ return ResponseBuilder.build(
1334
+ 500,
1335
+ f"Cmd all fail: {e}",
1336
+ {"inventory": inventory, "group": group, "cmd": cmd},
1337
+ str(e),
1338
+ )
1339
+
1340
+
1341
+ @mcp.tool(
1342
+ annotations={
1343
+ "title": "Copy SSH Config to All",
1344
+ "readOnlyHint": False,
1345
+ "destructiveHint": True,
1346
+ "idempotentHint": False,
1347
+ },
1348
+ tags={"remote_access"},
1349
+ )
1350
+ async def copy_ssh_config_on_inventory(
1351
+ inventory: str = Field(
1352
+ description="YAML inventory path.",
1353
+ default=os.environ.get("TUNNEL_INVENTORY", None),
1354
+ ),
1355
+ cfg: str = Field(description="Local SSH config path.", default=None),
1356
+ rmt_cfg: str = Field(
1357
+ description="Remote path.", default=os.path.expanduser("~/.ssh/config")
1358
+ ),
1359
+ group: str = Field(
1360
+ description="Target group.",
1361
+ default=os.environ.get("TUNNEL_INVENTORY_GROUP", "all"),
1362
+ ),
1363
+ parallel: bool = Field(
1364
+ description="Run parallel.",
1365
+ default=to_boolean(os.environ.get("TUNNEL_PARALLEL", False)),
1366
+ ),
1367
+ max_threads: int = Field(
1368
+ description="Max threads.",
1369
+ default=to_integer(os.environ.get("TUNNEL_MAX_THREADS", "6")),
1370
+ ),
1371
+ log: Optional[str] = Field(
1372
+ description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
1373
+ ),
1374
+ ctx: Context = Field(description="MCP context.", default=None),
1375
+ ) -> Dict:
1376
+ """Copy SSH config to all hosts in YAML group. Expected return object type: dict"""
1377
+ logger = logging.getLogger("TunnelServer")
1378
+ if error := setup_logging(log, logger):
1379
+ return error
1380
+ logger.debug(f"Copy SSH config: inv={inventory}, group={group}")
1381
+
1382
+ if not inventory or not cfg:
1383
+ logger.error("Need inventory, cfg")
1384
+ return ResponseBuilder.build(
1385
+ 400,
1386
+ "Need inventory, cfg",
1387
+ {
1388
+ "inventory": inventory,
1389
+ "group": group,
1390
+ "cfg": cfg,
1391
+ "rmt_cfg": rmt_cfg,
1392
+ },
1393
+ errors=["Need inventory, cfg"],
1394
+ )
1395
+
1396
+ if not os.path.exists(cfg):
1397
+ logger.error(f"No cfg file: {cfg}")
1398
+ return ResponseBuilder.build(
1399
+ 400,
1400
+ f"No cfg file: {cfg}",
1401
+ {
1402
+ "inventory": inventory,
1403
+ "group": group,
1404
+ "cfg": cfg,
1405
+ "rmt_cfg": rmt_cfg,
1406
+ },
1407
+ errors=[f"No cfg file: {cfg}"],
1408
+ )
1409
+
1410
+ try:
1411
+ hosts, error = load_inventory(inventory, group, logger)
1412
+ if error:
1413
+ return error
1414
+
1415
+ total = len(hosts)
1416
+ if ctx:
1417
+ await ctx.report_progress(progress=0, total=total)
1418
+ logger.debug(f"Progress: 0/{total}")
1419
+
1420
+ results, files, locations, errors = [], [], [], []
1421
+
1422
+ async def copy_host(h: Dict) -> Dict:
1423
+ try:
1424
+ t = Tunnel(
1425
+ remote_host=h["hostname"],
1426
+ username=h["username"],
1427
+ password=h.get("password"),
1428
+ identity_file=h.get("key_path"),
1429
+ )
1430
+ t.copy_ssh_config(cfg, rmt_cfg)
1431
+ logger.info(f"Copied cfg to {rmt_cfg} on {h['hostname']}")
1432
+ return {
1433
+ "hostname": h["hostname"],
1434
+ "status": "success",
1435
+ "message": f"Copied cfg to {rmt_cfg}",
1436
+ "errors": [],
1437
+ }
1438
+ except Exception as e:
1439
+ logger.error(f"Copy fail {h['hostname']}: {e}")
1440
+ return {
1441
+ "hostname": h["hostname"],
1442
+ "status": "failed",
1443
+ "message": f"Copy fail: {e}",
1444
+ "errors": [str(e)],
1445
+ }
1446
+ finally:
1447
+ if "t" in locals():
1448
+ t.close()
1449
+
1450
+ if parallel:
1451
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as ex:
1452
+ futures = [
1453
+ ex.submit(lambda h: asyncio.run(copy_host(h)), h) for h in hosts
1454
+ ]
1455
+ for i, f in enumerate(concurrent.futures.as_completed(futures), 1):
1456
+ try:
1457
+ r = f.result()
1458
+ results.append(r)
1459
+ if r["status"] == "success":
1460
+ files.append(cfg)
1461
+ locations.append(f"{rmt_cfg} on {r['hostname']}")
1462
+ else:
1463
+ errors.extend(r["errors"])
1464
+ if ctx:
1465
+ await ctx.report_progress(progress=i, total=total)
1466
+ logger.debug(f"Progress: {i}/{total}")
1467
+ except Exception as e:
1468
+ logger.error(f"Parallel error: {e}")
1469
+ results.append(
1470
+ {
1471
+ "hostname": "unknown",
1472
+ "status": "failed",
1473
+ "message": f"Parallel error: {e}",
1474
+ "errors": [str(e)],
1475
+ }
1476
+ )
1477
+ errors.append(str(e))
1478
+ else:
1479
+ for i, h in enumerate(hosts, 1):
1480
+ r = await copy_host(h)
1481
+ results.append(r)
1482
+ if r["status"] == "success":
1483
+ files.append(cfg)
1484
+ locations.append(f"{rmt_cfg} on {r['hostname']}")
1485
+ else:
1486
+ errors.extend(r["errors"])
1487
+ if ctx:
1488
+ await ctx.report_progress(progress=i, total=total)
1489
+ logger.debug(f"Progress: {i}/{total}")
1490
+
1491
+ logger.debug(f"Done SSH config copy for {group}")
1492
+ msg = (
1493
+ f"Copied cfg to {group}"
1494
+ if not errors
1495
+ else f"Copy failed for some in {group}"
1496
+ )
1497
+ return ResponseBuilder.build(
1498
+ 200 if not errors else 500,
1499
+ msg,
1500
+ {
1501
+ "inventory": inventory,
1502
+ "group": group,
1503
+ "cfg": cfg,
1504
+ "rmt_cfg": rmt_cfg,
1505
+ "host_results": results,
1506
+ },
1507
+ "; ".join(errors),
1508
+ files,
1509
+ locations,
1510
+ errors,
1511
+ )
1512
+
1513
+ except Exception as e:
1514
+ logger.error(f"Copy all fail: {e}")
1515
+ return ResponseBuilder.build(
1516
+ 500,
1517
+ f"Copy all fail: {e}",
1518
+ {
1519
+ "inventory": inventory,
1520
+ "group": group,
1521
+ "cfg": cfg,
1522
+ "rmt_cfg": rmt_cfg,
1523
+ },
1524
+ str(e),
1525
+ )
1526
+
1527
+
1528
+ @mcp.tool(
1529
+ annotations={
1530
+ "title": "Rotate SSH Keys for All",
1531
+ "readOnlyHint": False,
1532
+ "destructiveHint": True,
1533
+ "idempotentHint": False,
1534
+ },
1535
+ tags={"remote_access"},
1536
+ )
1537
+ async def rotate_ssh_key_on_inventory(
1538
+ inventory: str = Field(
1539
+ description="YAML inventory path.",
1540
+ default=os.environ.get("TUNNEL_INVENTORY", None),
1541
+ ),
1542
+ key_pfx: str = Field(
1543
+ description="Prefix for new keys.", default=os.path.expanduser("~/.ssh/id_")
1544
+ ),
1545
+ key_type: str = Field(
1546
+ description="Key type to generate (rsa or ed25519).", default="ed25519"
1547
+ ),
1548
+ group: str = Field(
1549
+ description="Target group.",
1550
+ default=os.environ.get("TUNNEL_INVENTORY_GROUP", "all"),
1551
+ ),
1552
+ parallel: bool = Field(
1553
+ description="Run parallel.",
1554
+ default=to_boolean(os.environ.get("TUNNEL_PARALLEL", False)),
1555
+ ),
1556
+ max_threads: int = Field(
1557
+ description="Max threads.",
1558
+ default=to_integer(os.environ.get("TUNNEL_MAX_THREADS", "6")),
1559
+ ),
1560
+ log: Optional[str] = Field(
1561
+ description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
1562
+ ),
1563
+ ctx: Context = Field(description="MCP context.", default=None),
1564
+ ) -> Dict:
1565
+ """Rotate SSH keys for all hosts in YAML group. Expected return object type: dict"""
1566
+ logger = logging.getLogger("TunnelServer")
1567
+ if error := setup_logging(log, logger):
1568
+ return error
1569
+ logger.debug(
1570
+ f"Rotate SSH keys: inv={inventory}, group={group}, key_type={key_type}"
1571
+ )
1572
+
1573
+ if not inventory:
1574
+ logger.error("Need inventory")
1575
+ return ResponseBuilder.build(
1576
+ 400,
1577
+ "Need inventory",
1578
+ {
1579
+ "inventory": inventory,
1580
+ "group": group,
1581
+ "key_pfx": key_pfx,
1582
+ "key_type": key_type,
1583
+ },
1584
+ errors=["Need inventory"],
1585
+ )
1586
+ if key_type not in ["rsa", "ed25519"]:
1587
+ logger.error(f"Invalid key_type: {key_type}")
1588
+ return ResponseBuilder.build(
1589
+ 400,
1590
+ f"Invalid key_type: {key_type}",
1591
+ {
1592
+ "inventory": inventory,
1593
+ "group": group,
1594
+ "key_pfx": key_pfx,
1595
+ "key_type": key_type,
1596
+ },
1597
+ errors=["key_type must be 'rsa' or 'ed25519'"],
1598
+ )
1599
+
1600
+ try:
1601
+ hosts, error = load_inventory(inventory, group, logger)
1602
+ if error:
1603
+ return error
1604
+
1605
+ total = len(hosts)
1606
+ if ctx:
1607
+ await ctx.report_progress(progress=0, total=total)
1608
+ logger.debug(f"Progress: 0/{total}")
1609
+
1610
+ results, files, locations, errors = [], [], [], []
1611
+
1612
+ async def rotate_host(h: Dict) -> Dict:
1613
+ key = os.path.expanduser(key_pfx + h["hostname"])
1614
+ try:
1615
+ t = Tunnel(
1616
+ remote_host=h["hostname"],
1617
+ username=h["username"],
1618
+ password=h.get("password"),
1619
+ identity_file=h.get("key_path"),
1620
+ )
1621
+ t.rotate_ssh_key(key, key_type=key_type)
1622
+ logger.info(f"Rotated {key_type} key for {h['hostname']}: {key}")
1623
+ return {
1624
+ "hostname": h["hostname"],
1625
+ "status": "success",
1626
+ "message": f"Rotated {key_type} key to {key}",
1627
+ "errors": [],
1628
+ "new_key_path": key,
1629
+ }
1630
+ except Exception as e:
1631
+ logger.error(f"Rotate fail {h['hostname']}: {e}")
1632
+ return {
1633
+ "hostname": h["hostname"],
1634
+ "status": "failed",
1635
+ "message": f"Rotate fail: {e}",
1636
+ "errors": [str(e)],
1637
+ "new_key_path": key,
1638
+ }
1639
+ finally:
1640
+ if "t" in locals():
1641
+ t.close()
1642
+
1643
+ if parallel:
1644
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as ex:
1645
+ futures = [
1646
+ ex.submit(lambda h: asyncio.run(rotate_host(h)), h) for h in hosts
1647
+ ]
1648
+ for i, f in enumerate(concurrent.fences.as_completed(futures), 1):
1649
+ try:
1650
+ r = f.result()
1651
+ results.append(r)
1652
+ if r["status"] == "success":
1653
+ files.append(r["new_key_path"] + ".pub")
1654
+ locations.append(
1655
+ f"~/.ssh/authorized_keys on {r['hostname']}"
1656
+ )
1657
+ else:
1658
+ errors.extend(r["errors"])
1659
+ if ctx:
1660
+ await ctx.report_progress(progress=i, total=total)
1661
+ logger.debug(f"Progress: {i}/{total}")
1662
+ except Exception as e:
1663
+ logger.error(f"Parallel error: {e}")
1664
+ results.append(
1665
+ {
1666
+ "hostname": "unknown",
1667
+ "status": "failed",
1668
+ "message": f"Parallel error: {e}",
1669
+ "errors": [str(e)],
1670
+ "new_key_path": None,
1671
+ }
1672
+ )
1673
+ errors.append(str(e))
1674
+ else:
1675
+ for i, h in enumerate(hosts, 1):
1676
+ r = await rotate_host(h)
1677
+ results.append(r)
1678
+ if r["status"] == "success":
1679
+ files.append(r["new_key_path"] + ".pub")
1680
+ locations.append(f"~/.ssh/authorized_keys on {r['hostname']}")
1681
+ else:
1682
+ errors.extend(r["errors"])
1683
+ if ctx:
1684
+ await ctx.report_progress(progress=i, total=total)
1685
+ logger.debug(f"Progress: {i}/{total}")
1686
+
1687
+ logger.debug(f"Done SSH key rotate for {group}")
1688
+ msg = (
1689
+ f"Rotated {key_type} keys for {group}"
1690
+ if not errors
1691
+ else f"Rotate failed for some in {group}"
1692
+ )
1693
+ return ResponseBuilder.build(
1694
+ 200 if not errors else 500,
1695
+ msg,
1696
+ {
1697
+ "inventory": inventory,
1698
+ "group": group,
1699
+ "key_pfx": key_pfx,
1700
+ "key_type": key_type,
1701
+ "host_results": results,
1702
+ },
1703
+ "; ".join(errors),
1704
+ files,
1705
+ locations,
1706
+ errors,
1707
+ )
1708
+
1709
+ except Exception as e:
1710
+ logger.error(f"Rotate all fail: {e}")
1711
+ return ResponseBuilder.build(
1712
+ 500,
1713
+ f"Rotate all fail: {e}",
1714
+ {
1715
+ "inventory": inventory,
1716
+ "group": group,
1717
+ "key_pfx": key_pfx,
1718
+ "key_type": key_type,
1719
+ },
1720
+ str(e),
1721
+ )
1722
+
1723
+
1724
+ @mcp.tool(
1725
+ annotations={
1726
+ "title": "Upload File to All Hosts",
1727
+ "readOnlyHint": False,
1728
+ "destructiveHint": True,
1729
+ "idempotentHint": False,
1730
+ },
1731
+ tags={"remote_access"},
1732
+ )
1733
+ async def send_file_to_inventory(
1734
+ inventory: str = Field(
1735
+ description="YAML inventory path.",
1736
+ default=os.environ.get("TUNNEL_INVENTORY", None),
1737
+ ),
1738
+ lpath: str = Field(description="Local file path.", default=None),
1739
+ rpath: str = Field(description="Remote destination path.", default=None),
1740
+ group: str = Field(
1741
+ description="Target group.",
1742
+ default=os.environ.get("TUNNEL_INVENTORY_GROUP", "all"),
1743
+ ),
1744
+ parallel: bool = Field(
1745
+ description="Run parallel.",
1746
+ default=to_boolean(os.environ.get("TUNNEL_PARALLEL", False)),
1747
+ ),
1748
+ max_threads: int = Field(
1749
+ description="Max threads.",
1750
+ default=to_integer(os.environ.get("TUNNEL_MAX_THREADS", "5")),
1751
+ ),
1752
+ log: Optional[str] = Field(
1753
+ description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
1754
+ ),
1755
+ ctx: Context = Field(description="MCP context.", default=None),
1756
+ ) -> Dict:
1757
+ """Upload a file to all hosts in the specified inventory group. Expected return object type: dict"""
1758
+ logger = logging.getLogger("TunnelServer")
1759
+ lpath = os.path.abspath(os.path.expanduser(lpath)) # Normalize
1760
+ rpath = os.path.expanduser(rpath)
1761
+ logger.debug(
1762
+ f"Normalized: lpath={lpath} (exists={os.path.exists(lpath)}, isfile={os.path.isfile(lpath)}), rpath={rpath}, CWD={os.getcwd()}"
1763
+ )
1764
+ if error := setup_logging(log, logger):
1765
+ return error
1766
+ logger.debug(
1767
+ f"Upload file all: inv={inventory}, group={group}, local={lpath}, remote={rpath}"
1768
+ )
1769
+ if not inventory or not lpath or not rpath:
1770
+ logger.error("Need inventory, lpath, rpath")
1771
+ return ResponseBuilder.build(
1772
+ 400,
1773
+ "Need inventory, lpath, rpath",
1774
+ {"inventory": inventory, "group": group, "lpath": lpath, "rpath": rpath},
1775
+ errors=["Need inventory, lpath, rpath"],
1776
+ )
1777
+ if not os.path.exists(lpath) or not os.path.isfile(lpath):
1778
+ logger.error(f"Invalid file: {lpath}")
1779
+ return ResponseBuilder.build(
1780
+ 400,
1781
+ f"Invalid file: {lpath}",
1782
+ {"inventory": inventory, "group": group, "lpath": lpath, "rpath": rpath},
1783
+ errors=[f"Invalid file: {lpath}"],
1784
+ )
1785
+ try:
1786
+ hosts, error = load_inventory(inventory, group, logger)
1787
+ if error:
1788
+ return error
1789
+ total = len(hosts)
1790
+ if ctx:
1791
+ await ctx.report_progress(progress=0, total=total)
1792
+ logger.debug(f"Progress: 0/{total}")
1793
+
1794
+ async def send_host(h: Dict) -> Dict:
1795
+ host = h["hostname"]
1796
+ try:
1797
+ t = Tunnel(
1798
+ remote_host=host,
1799
+ username=h["username"],
1800
+ password=h.get("password"),
1801
+ identity_file=h.get("key_path"),
1802
+ )
1803
+ t.connect()
1804
+ sftp = t.ssh_client.open_sftp()
1805
+ transferred = 0
1806
+
1807
+ def progress_callback(transf, total):
1808
+ nonlocal transferred
1809
+ transferred = transf
1810
+ if ctx:
1811
+ asyncio.ensure_future(
1812
+ ctx.report_progress(progress=transf, total=total)
1813
+ )
1814
+
1815
+ sftp.put(lpath, rpath, callback=progress_callback)
1816
+ sftp.close()
1817
+ logger.info(f"Host {host}: Uploaded {lpath} to {rpath}")
1818
+ return {
1819
+ "hostname": host,
1820
+ "status": "success",
1821
+ "message": f"Uploaded {lpath} to {rpath}",
1822
+ "errors": [],
1823
+ }
1824
+ except Exception as e:
1825
+ logger.error(f"Upload fail {host}: {e}")
1826
+ return {
1827
+ "hostname": host,
1828
+ "status": "failed",
1829
+ "message": f"Upload fail: {e}",
1830
+ "errors": [str(e)],
1831
+ }
1832
+ finally:
1833
+ if "t" in locals():
1834
+ t.close()
1835
+
1836
+ results, files, locations, errors = [], [lpath], [], []
1837
+ if parallel:
1838
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as ex:
1839
+ futures = [
1840
+ ex.submit(lambda h: asyncio.run(send_host(h)), h) for h in hosts
1841
+ ]
1842
+ for i, f in enumerate(concurrent.futures.as_completed(futures), 1):
1843
+ try:
1844
+ r = f.result()
1845
+ results.append(r)
1846
+ if r["status"] == "success":
1847
+ locations.append(f"{rpath} on {r['hostname']}")
1848
+ else:
1849
+ errors.extend(r["errors"])
1850
+ if ctx:
1851
+ await ctx.report_progress(progress=i, total=total)
1852
+ logger.debug(f"Progress: {i}/{total}")
1853
+ except Exception as e:
1854
+ logger.error(f"Parallel error: {e}")
1855
+ results.append(
1856
+ {
1857
+ "hostname": "unknown",
1858
+ "status": "failed",
1859
+ "message": f"Parallel error: {e}",
1860
+ "errors": [str(e)],
1861
+ }
1862
+ )
1863
+ errors.append(str(e))
1864
+ else:
1865
+ for i, h in enumerate(hosts, 1):
1866
+ r = await send_host(h)
1867
+ results.append(r)
1868
+ if r["status"] == "success":
1869
+ locations.append(f"{rpath} on {r['hostname']}")
1870
+ else:
1871
+ errors.extend(r["errors"])
1872
+ if ctx:
1873
+ await ctx.report_progress(progress=i, total=total)
1874
+ logger.debug(f"Progress: {i}/{total}")
1875
+
1876
+ logger.debug(f"Done file upload for {group}")
1877
+ msg = (
1878
+ f"Uploaded {lpath} to {group}"
1879
+ if not errors
1880
+ else f"Upload failed for some in {group}"
1881
+ )
1882
+ return ResponseBuilder.build(
1883
+ 200 if not errors else 500,
1884
+ msg,
1885
+ {
1886
+ "inventory": inventory,
1887
+ "group": group,
1888
+ "lpath": lpath,
1889
+ "rpath": rpath,
1890
+ "host_results": results,
1891
+ },
1892
+ "; ".join(errors),
1893
+ files,
1894
+ locations,
1895
+ errors,
1896
+ )
1897
+ except Exception as e:
1898
+ logger.error(f"Upload all fail: {e}")
1899
+ return ResponseBuilder.build(
1900
+ 500,
1901
+ f"Upload all fail: {e}",
1902
+ {"inventory": inventory, "group": group, "lpath": lpath, "rpath": rpath},
1903
+ str(e),
1904
+ )
1905
+
1906
+
1907
+ @mcp.tool(
1908
+ annotations={
1909
+ "title": "Download File from All Hosts",
1910
+ "readOnlyHint": False,
1911
+ "destructiveHint": False,
1912
+ "idempotentHint": True,
1913
+ },
1914
+ tags={"remote_access"},
1915
+ )
1916
+ async def receive_file_from_inventory(
1917
+ inventory: str = Field(
1918
+ description="YAML inventory path.",
1919
+ default=os.environ.get("TUNNEL_INVENTORY", None),
1920
+ ),
1921
+ rpath: str = Field(description="Remote file path to download.", default=None),
1922
+ lpath_prefix: str = Field(
1923
+ description="Local directory path prefix to save files.", default=None
1924
+ ),
1925
+ group: str = Field(
1926
+ description="Target group.",
1927
+ default=os.environ.get("TUNNEL_INVENTORY_GROUP", "all"),
1928
+ ),
1929
+ parallel: bool = Field(
1930
+ description="Run parallel.",
1931
+ default=to_boolean(os.environ.get("TUNNEL_PARALLEL", False)),
1932
+ ),
1933
+ max_threads: int = Field(
1934
+ description="Max threads.",
1935
+ default=to_integer(os.environ.get("TUNNEL_MAX_THREADS", "5")),
1936
+ ),
1937
+ log: Optional[str] = Field(
1938
+ description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
1939
+ ),
1940
+ ctx: Context = Field(description="MCP context.", default=None),
1941
+ ) -> Dict:
1942
+ """Download a file from all hosts in the specified inventory group. Expected return object type: dict"""
1943
+ logger = logging.getLogger("TunnelServer")
1944
+ if error := setup_logging(log, logger):
1945
+ return error
1946
+ logger.debug(
1947
+ f"Download file all: inv={inventory}, group={group}, remote={rpath}, local_prefix={lpath_prefix}"
1948
+ )
1949
+ if not inventory or not rpath or not lpath_prefix:
1950
+ logger.error("Need inventory, rpath, lpath_prefix")
1951
+ return ResponseBuilder.build(
1952
+ 400,
1953
+ "Need inventory, rpath, lpath_prefix",
1954
+ {
1955
+ "inventory": inventory,
1956
+ "group": group,
1957
+ "rpath": rpath,
1958
+ "lpath_prefix": lpath_prefix,
1959
+ },
1960
+ errors=["Need inventory, rpath, lpath_prefix"],
1961
+ )
1962
+ try:
1963
+ os.makedirs(lpath_prefix, exist_ok=True)
1964
+ hosts, error = load_inventory(inventory, group, logger)
1965
+ if error:
1966
+ return error
1967
+ total = len(hosts)
1968
+ if ctx:
1969
+ await ctx.report_progress(progress=0, total=total)
1970
+ logger.debug(f"Progress: 0/{total}")
1971
+
1972
+ async def receive_host(h: Dict) -> Dict:
1973
+ host = h["hostname"]
1974
+ lpath = os.path.join(lpath_prefix, host, os.path.basename(rpath))
1975
+ os.makedirs(os.path.dirname(lpath), exist_ok=True)
1976
+ try:
1977
+ t = Tunnel(
1978
+ remote_host=host,
1979
+ username=h["username"],
1980
+ password=h.get("password"),
1981
+ identity_file=h.get("key_path"),
1982
+ )
1983
+ t.connect()
1984
+ sftp = t.ssh_client.open_sftp()
1985
+ sftp.stat(rpath)
1986
+ transferred = 0
1987
+
1988
+ def progress_callback(transf, total):
1989
+ nonlocal transferred
1990
+ transferred = transf
1991
+ if ctx:
1992
+ asyncio.ensure_future(
1993
+ ctx.report_progress(progress=transf, total=total)
1994
+ )
1995
+
1996
+ sftp.get(rpath, lpath, callback=progress_callback)
1997
+ sftp.close()
1998
+ logger.info(f"Host {host}: Downloaded {rpath} to {lpath}")
1999
+ return {
2000
+ "hostname": host,
2001
+ "status": "success",
2002
+ "message": f"Downloaded {rpath} to {lpath}",
2003
+ "errors": [],
2004
+ "local_path": lpath,
2005
+ }
2006
+ except Exception as e:
2007
+ logger.error(f"Download fail {host}: {e}")
2008
+ return {
2009
+ "hostname": host,
2010
+ "status": "failed",
2011
+ "message": f"Download fail: {e}",
2012
+ "errors": [str(e)],
2013
+ "local_path": lpath,
2014
+ }
2015
+ finally:
2016
+ if "t" in locals():
2017
+ t.close()
2018
+
2019
+ results, files, locations, errors = [], [], [], []
2020
+ if parallel:
2021
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as ex:
2022
+ futures = [
2023
+ ex.submit(lambda h: asyncio.run(receive_host(h)), h) for h in hosts
2024
+ ]
2025
+ for i, f in enumerate(concurrent.futures.as_completed(futures), 1):
2026
+ try:
2027
+ r = f.result()
2028
+ results.append(r)
2029
+ if r["status"] == "success":
2030
+ files.append(rpath)
2031
+ locations.append(r["local_path"])
2032
+ else:
2033
+ errors.extend(r["errors"])
2034
+ if ctx:
2035
+ await ctx.report_progress(progress=i, total=total)
2036
+ logger.debug(f"Progress: {i}/{total}")
2037
+ except Exception as e:
2038
+ logger.error(f"Parallel error: {e}")
2039
+ results.append(
2040
+ {
2041
+ "hostname": "unknown",
2042
+ "status": "failed",
2043
+ "message": f"Parallel error: {e}",
2044
+ "errors": [str(e)],
2045
+ "local_path": None,
2046
+ }
2047
+ )
2048
+ errors.append(str(e))
2049
+ else:
2050
+ for i, h in enumerate(hosts, 1):
2051
+ r = await receive_host(h)
2052
+ results.append(r)
2053
+ if r["status"] == "success":
2054
+ files.append(rpath)
2055
+ locations.append(r["local_path"])
2056
+ else:
2057
+ errors.extend(r["errors"])
2058
+ if ctx:
2059
+ await ctx.report_progress(progress=i, total=total)
2060
+ logger.debug(f"Progress: {i}/{total}")
2061
+
2062
+ logger.debug(f"Done file download for {group}")
2063
+ msg = (
2064
+ f"Downloaded {rpath} from {group}"
2065
+ if not errors
2066
+ else f"Download failed for some in {group}"
2067
+ )
2068
+ return ResponseBuilder.build(
2069
+ 200 if not errors else 500,
2070
+ msg,
2071
+ {
2072
+ "inventory": inventory,
2073
+ "group": group,
2074
+ "rpath": rpath,
2075
+ "lpath_prefix": lpath_prefix,
2076
+ "host_results": results,
2077
+ },
2078
+ "; ".join(errors),
2079
+ files,
2080
+ locations,
2081
+ errors,
2082
+ )
2083
+ except Exception as e:
2084
+ logger.error(f"Download all fail: {e}")
2085
+ return ResponseBuilder.build(
2086
+ 500,
2087
+ f"Download all fail: {e}",
2088
+ {
2089
+ "inventory": inventory,
2090
+ "group": group,
2091
+ "rpath": rpath,
2092
+ "lpath_prefix": lpath_prefix,
2093
+ },
2094
+ str(e),
2095
+ )
270
2096
 
271
2097
 
272
2098
  def tunnel_manager_mcp():