tunnel-manager 0.0.4__py3-none-any.whl → 1.0.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.
Potentially problematic release.
This version of tunnel-manager might be problematic. Click here for more details.
- tests/test_tunnel.py +78 -0
- tunnel_manager/__init__.py +22 -7
- tunnel_manager/tunnel_manager.py +770 -69
- tunnel_manager/tunnel_manager_mcp.py +1478 -151
- tunnel_manager-1.0.0.dist-info/METADATA +391 -0
- tunnel_manager-1.0.0.dist-info/RECORD +11 -0
- {tunnel_manager-0.0.4.dist-info → tunnel_manager-1.0.0.dist-info}/entry_points.txt +1 -0
- {tunnel_manager-0.0.4.dist-info → tunnel_manager-1.0.0.dist-info}/top_level.txt +1 -0
- tunnel_manager-0.0.4.dist-info/METADATA +0 -190
- tunnel_manager-0.0.4.dist-info/RECORD +0 -10
- {tunnel_manager-0.0.4.dist-info → tunnel_manager-1.0.0.dist-info}/WHEEL +0 -0
- {tunnel_manager-0.0.4.dist-info → tunnel_manager-1.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,21 +1,126 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
class ResponseBuilder:
|
|
43
|
+
@staticmethod
|
|
44
|
+
def build(
|
|
45
|
+
status: int,
|
|
46
|
+
msg: str,
|
|
47
|
+
details: Dict,
|
|
48
|
+
err: str = "",
|
|
49
|
+
files: List = None,
|
|
50
|
+
locs: List = None,
|
|
51
|
+
errors: List = None,
|
|
52
|
+
) -> Dict:
|
|
53
|
+
return {
|
|
54
|
+
"status_code": status,
|
|
55
|
+
"message": msg,
|
|
56
|
+
"stdout": "",
|
|
57
|
+
"stderr": err,
|
|
58
|
+
"files_copied": files or [],
|
|
59
|
+
"locations_copied_to": locs or [],
|
|
60
|
+
"details": details,
|
|
61
|
+
"errors": errors or ([err] if err else []),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def setup_logging(log_file: Optional[str], logger: logging.Logger) -> Dict:
|
|
66
|
+
if not log_file:
|
|
67
|
+
return {}
|
|
68
|
+
try:
|
|
69
|
+
log_dir = os.path.dirname(os.path.abspath(log_file)) or os.getcwd()
|
|
70
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
71
|
+
logging.basicConfig(
|
|
72
|
+
filename=log_file,
|
|
73
|
+
level=logging.DEBUG,
|
|
74
|
+
format="%(asctime)s - %(name)s - %(level)s - %(msg)s",
|
|
75
|
+
)
|
|
76
|
+
return {}
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.error(f"Log config fail: {e}")
|
|
79
|
+
return ResponseBuilder.build(500, f"Log config fail: {e}", {}, str(e))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_inventory(
|
|
83
|
+
inventory_path: str, group: str, logger: logging.Logger
|
|
84
|
+
) -> tuple[List[Dict], Dict]:
|
|
85
|
+
try:
|
|
86
|
+
with open(inventory_path, "r") as f:
|
|
87
|
+
inv = yaml.safe_load(f)
|
|
88
|
+
hosts = []
|
|
89
|
+
if group in inv and isinstance(inv[group], dict) and "hosts" in inv[group]:
|
|
90
|
+
for host, vars in inv[group]["hosts"].items():
|
|
91
|
+
entry = {
|
|
92
|
+
"hostname": vars.get("ansible_host", host),
|
|
93
|
+
"username": vars.get("ansible_user"),
|
|
94
|
+
"password": vars.get("ansible_ssh_pass"),
|
|
95
|
+
"key_path": vars.get("ansible_ssh_private_key_file"),
|
|
96
|
+
}
|
|
97
|
+
if not entry["username"]:
|
|
98
|
+
logger.error(f"Skip {entry['hostname']}: no username")
|
|
99
|
+
continue
|
|
100
|
+
hosts.append(entry)
|
|
101
|
+
else:
|
|
102
|
+
return [], ResponseBuilder.build(
|
|
103
|
+
400,
|
|
104
|
+
f"Group '{group}' invalid",
|
|
105
|
+
{"inventory_path": inventory_path, "group": group},
|
|
106
|
+
errors=[f"Group '{group}' invalid"],
|
|
107
|
+
)
|
|
108
|
+
if not hosts:
|
|
109
|
+
return [], ResponseBuilder.build(
|
|
110
|
+
400,
|
|
111
|
+
f"No hosts in group '{group}'",
|
|
112
|
+
{"inventory_path": inventory_path, "group": group},
|
|
113
|
+
errors=[f"No hosts in group '{group}'"],
|
|
114
|
+
)
|
|
115
|
+
return hosts, {}
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.error(f"Load inv fail: {e}")
|
|
118
|
+
return [], ResponseBuilder.build(
|
|
119
|
+
500,
|
|
120
|
+
f"Load inv fail: {e}",
|
|
121
|
+
{"inventory_path": inventory_path, "group": group},
|
|
122
|
+
str(e),
|
|
123
|
+
)
|
|
19
124
|
|
|
20
125
|
|
|
21
126
|
@mcp.tool(
|
|
@@ -24,75 +129,90 @@ mcp = FastMCP(name="TunnelServer")
|
|
|
24
129
|
"readOnlyHint": True,
|
|
25
130
|
"destructiveHint": True,
|
|
26
131
|
"idempotentHint": False,
|
|
27
|
-
"openWorldHint": False,
|
|
28
132
|
},
|
|
29
133
|
tags={"remote_access"},
|
|
30
134
|
)
|
|
31
135
|
async def run_remote_command(
|
|
32
|
-
|
|
33
|
-
description="
|
|
34
|
-
|
|
136
|
+
host: str = Field(
|
|
137
|
+
description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
|
|
138
|
+
),
|
|
139
|
+
user: Optional[str] = Field(
|
|
140
|
+
description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
|
|
35
141
|
),
|
|
36
|
-
|
|
37
|
-
description="
|
|
38
|
-
default=os.environ.get("TUNNEL_REMOTE_PORT", None),
|
|
142
|
+
password: Optional[str] = Field(
|
|
143
|
+
description="Password.", default=os.environ.get("TUNNEL_PASSWORD", None)
|
|
39
144
|
),
|
|
40
|
-
|
|
41
|
-
description="
|
|
145
|
+
port: int = Field(
|
|
146
|
+
description="Port.", default=int(os.environ.get("TUNNEL_REMOTE_PORT", 22))
|
|
42
147
|
),
|
|
43
|
-
|
|
44
|
-
|
|
148
|
+
cmd: str = Field(description="Shell command.", default=None),
|
|
149
|
+
id_file: Optional[str] = Field(
|
|
150
|
+
description="Private key path.",
|
|
45
151
|
default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
|
|
46
152
|
),
|
|
47
|
-
|
|
48
|
-
description="
|
|
153
|
+
certificate: Optional[str] = Field(
|
|
154
|
+
description="Teleport certificate.",
|
|
49
155
|
default=os.environ.get("TUNNEL_CERTIFICATE", None),
|
|
50
156
|
),
|
|
51
|
-
|
|
52
|
-
description="
|
|
157
|
+
proxy: Optional[str] = Field(
|
|
158
|
+
description="Teleport proxy.",
|
|
53
159
|
default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
|
|
54
160
|
),
|
|
55
|
-
|
|
56
|
-
description="
|
|
57
|
-
default=os.environ.get("TUNNEL_LOG_FILE", None),
|
|
161
|
+
cfg: str = Field(
|
|
162
|
+
description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
|
|
58
163
|
),
|
|
59
|
-
|
|
60
|
-
description="
|
|
164
|
+
log: Optional[str] = Field(
|
|
165
|
+
description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
|
|
61
166
|
),
|
|
62
|
-
|
|
63
|
-
|
|
167
|
+
ctx: Context = Field(description="MCP context.", default=None),
|
|
168
|
+
) -> Dict:
|
|
169
|
+
"""Run shell command on remote host. Expected return object type: dict"""
|
|
64
170
|
logger = logging.getLogger("TunnelServer")
|
|
65
|
-
logger
|
|
66
|
-
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
171
|
+
if err := setup_logging(log, logger):
|
|
172
|
+
return err
|
|
173
|
+
logger.debug(f"Run cmd: host={host}, cmd={cmd}")
|
|
174
|
+
if not host or not cmd:
|
|
175
|
+
logger.error("Need host, cmd")
|
|
176
|
+
return ResponseBuilder.build(
|
|
177
|
+
400, "Need host, cmd", {"host": host, "cmd": cmd}, errors=["Need host, cmd"]
|
|
178
|
+
)
|
|
72
179
|
try:
|
|
73
|
-
|
|
74
|
-
remote_host,
|
|
180
|
+
t = Tunnel(
|
|
181
|
+
remote_host=host,
|
|
182
|
+
username=user,
|
|
183
|
+
password=password,
|
|
184
|
+
port=port,
|
|
185
|
+
identity_file=id_file,
|
|
186
|
+
certificate_file=certificate,
|
|
187
|
+
proxy_command=proxy,
|
|
188
|
+
ssh_config_file=cfg,
|
|
75
189
|
)
|
|
76
|
-
|
|
77
190
|
if ctx:
|
|
78
191
|
await ctx.report_progress(progress=0, total=100)
|
|
79
|
-
logger.debug("
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
out, err = tunnel.run_command(command)
|
|
83
|
-
|
|
192
|
+
logger.debug("Progress: 0/100")
|
|
193
|
+
t.connect()
|
|
194
|
+
out, err = t.run_command(cmd)
|
|
84
195
|
if ctx:
|
|
85
196
|
await ctx.report_progress(progress=100, total=100)
|
|
86
|
-
logger.debug("
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
197
|
+
logger.debug("Progress: 100/100")
|
|
198
|
+
logger.debug(f"Cmd out: {out}, err: {err}")
|
|
199
|
+
return ResponseBuilder.build(
|
|
200
|
+
200,
|
|
201
|
+
f"Cmd '{cmd}' done on {host}",
|
|
202
|
+
{"host": host, "cmd": cmd},
|
|
203
|
+
err,
|
|
204
|
+
[],
|
|
205
|
+
[],
|
|
206
|
+
errors=[],
|
|
207
|
+
)
|
|
90
208
|
except Exception as e:
|
|
91
|
-
logger.error(f"
|
|
92
|
-
|
|
209
|
+
logger.error(f"Cmd fail: {e}")
|
|
210
|
+
return ResponseBuilder.build(
|
|
211
|
+
500, f"Cmd fail: {e}", {"host": host, "cmd": cmd}, str(e)
|
|
212
|
+
)
|
|
93
213
|
finally:
|
|
94
|
-
if "
|
|
95
|
-
|
|
214
|
+
if "t" in locals():
|
|
215
|
+
t.close()
|
|
96
216
|
|
|
97
217
|
|
|
98
218
|
@mcp.tool(
|
|
@@ -101,86 +221,114 @@ async def run_remote_command(
|
|
|
101
221
|
"readOnlyHint": False,
|
|
102
222
|
"destructiveHint": True,
|
|
103
223
|
"idempotentHint": False,
|
|
104
|
-
"openWorldHint": False,
|
|
105
224
|
},
|
|
106
225
|
tags={"remote_access"},
|
|
107
226
|
)
|
|
108
227
|
async def upload_file(
|
|
109
|
-
|
|
110
|
-
description="
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
228
|
+
host: str = Field(
|
|
229
|
+
description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
|
|
230
|
+
),
|
|
231
|
+
user: Optional[str] = Field(
|
|
232
|
+
description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
|
|
233
|
+
),
|
|
234
|
+
password: Optional[str] = Field(
|
|
235
|
+
description="Password.", default=os.environ.get("TUNNEL_PASSWORD", None)
|
|
236
|
+
),
|
|
237
|
+
port: int = Field(
|
|
238
|
+
description="Port.", default=int(os.environ.get("TUNNEL_REMOTE_PORT", 22))
|
|
239
|
+
),
|
|
240
|
+
lpath: str = Field(description="Local file path.", default=None),
|
|
241
|
+
rpath: str = Field(description="Remote path.", default=None),
|
|
242
|
+
id_file: Optional[str] = Field(
|
|
243
|
+
description="Private key path.",
|
|
121
244
|
default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
|
|
122
245
|
),
|
|
123
|
-
|
|
124
|
-
description="
|
|
246
|
+
certificate: Optional[str] = Field(
|
|
247
|
+
description="Teleport certificate.",
|
|
125
248
|
default=os.environ.get("TUNNEL_CERTIFICATE", None),
|
|
126
249
|
),
|
|
127
|
-
|
|
128
|
-
description="
|
|
250
|
+
proxy: Optional[str] = Field(
|
|
251
|
+
description="Teleport proxy.",
|
|
129
252
|
default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
|
|
130
253
|
),
|
|
131
|
-
|
|
132
|
-
description="
|
|
133
|
-
default=os.environ.get("TUNNEL_LOG_FILE", None),
|
|
254
|
+
cfg: str = Field(
|
|
255
|
+
description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
|
|
134
256
|
),
|
|
135
|
-
|
|
136
|
-
description="
|
|
257
|
+
log: Optional[str] = Field(
|
|
258
|
+
description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
|
|
137
259
|
),
|
|
138
|
-
|
|
139
|
-
|
|
260
|
+
ctx: Context = Field(description="MCP context.", default=None),
|
|
261
|
+
) -> Dict:
|
|
262
|
+
"""Upload file to remote host. Expected return object type: dict"""
|
|
140
263
|
logger = logging.getLogger("TunnelServer")
|
|
141
|
-
logger
|
|
142
|
-
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
264
|
+
if err := setup_logging(log, logger):
|
|
265
|
+
return err
|
|
266
|
+
logger.debug(f"Upload: host={host}, local={lpath}, remote={rpath}")
|
|
267
|
+
if not host or not lpath or not rpath:
|
|
268
|
+
logger.error("Need host, lpath, rpath")
|
|
269
|
+
return ResponseBuilder.build(
|
|
270
|
+
400,
|
|
271
|
+
"Need host, lpath, rpath",
|
|
272
|
+
{"host": host, "lpath": lpath, "rpath": rpath},
|
|
273
|
+
errors=["Need host, lpath, rpath"],
|
|
274
|
+
)
|
|
275
|
+
if not os.path.exists(lpath):
|
|
276
|
+
logger.error(f"No file: {lpath}")
|
|
277
|
+
return ResponseBuilder.build(
|
|
278
|
+
400,
|
|
279
|
+
f"No file: {lpath}",
|
|
280
|
+
{"host": host, "lpath": lpath, "rpath": rpath},
|
|
281
|
+
errors=[f"No file: {lpath}"],
|
|
282
|
+
)
|
|
151
283
|
try:
|
|
152
|
-
|
|
153
|
-
remote_host,
|
|
284
|
+
t = Tunnel(
|
|
285
|
+
remote_host=host,
|
|
286
|
+
username=user,
|
|
287
|
+
password=password,
|
|
288
|
+
port=port,
|
|
289
|
+
identity_file=id_file,
|
|
290
|
+
certificate_file=certificate,
|
|
291
|
+
proxy_command=proxy,
|
|
292
|
+
ssh_config_file=cfg,
|
|
154
293
|
)
|
|
155
|
-
|
|
156
|
-
|
|
294
|
+
t.connect()
|
|
157
295
|
if ctx:
|
|
158
296
|
await ctx.report_progress(progress=0, total=100)
|
|
159
|
-
logger.debug("
|
|
160
|
-
|
|
161
|
-
sftp = tunnel.ssh_client.open_sftp()
|
|
162
|
-
file_size = os.path.getsize(local_path)
|
|
297
|
+
logger.debug("Progress: 0/100")
|
|
298
|
+
sftp = t.ssh_client.open_sftp()
|
|
163
299
|
transferred = 0
|
|
164
300
|
|
|
165
301
|
def progress_callback(transf, total):
|
|
166
302
|
nonlocal transferred
|
|
167
303
|
transferred = transf
|
|
304
|
+
if ctx:
|
|
305
|
+
asyncio.ensure_future(ctx.report_progress(progress=transf, total=total))
|
|
168
306
|
|
|
169
|
-
sftp.put(
|
|
170
|
-
|
|
307
|
+
sftp.put(lpath, rpath, callback=progress_callback)
|
|
171
308
|
if ctx:
|
|
172
309
|
await ctx.report_progress(progress=100, total=100)
|
|
173
|
-
logger.debug("
|
|
174
|
-
|
|
310
|
+
logger.debug("Progress: 100/100")
|
|
175
311
|
sftp.close()
|
|
176
|
-
logger.debug(f"
|
|
177
|
-
return
|
|
312
|
+
logger.debug(f"Uploaded: {lpath} -> {rpath}")
|
|
313
|
+
return ResponseBuilder.build(
|
|
314
|
+
200,
|
|
315
|
+
f"Uploaded to {rpath}",
|
|
316
|
+
{"host": host, "lpath": lpath, "rpath": rpath},
|
|
317
|
+
files=[lpath],
|
|
318
|
+
locs=[rpath],
|
|
319
|
+
errors=[],
|
|
320
|
+
)
|
|
178
321
|
except Exception as e:
|
|
179
|
-
logger.error(f"
|
|
180
|
-
|
|
322
|
+
logger.error(f"Upload fail: {e}")
|
|
323
|
+
return ResponseBuilder.build(
|
|
324
|
+
500,
|
|
325
|
+
f"Upload fail: {e}",
|
|
326
|
+
{"host": host, "lpath": lpath, "rpath": rpath},
|
|
327
|
+
str(e),
|
|
328
|
+
)
|
|
181
329
|
finally:
|
|
182
|
-
if "
|
|
183
|
-
|
|
330
|
+
if "t" in locals():
|
|
331
|
+
t.close()
|
|
184
332
|
|
|
185
333
|
|
|
186
334
|
@mcp.tool(
|
|
@@ -189,84 +337,1263 @@ async def upload_file(
|
|
|
189
337
|
"readOnlyHint": False,
|
|
190
338
|
"destructiveHint": False,
|
|
191
339
|
"idempotentHint": True,
|
|
192
|
-
"openWorldHint": False,
|
|
193
340
|
},
|
|
194
341
|
tags={"remote_access"},
|
|
195
342
|
)
|
|
196
343
|
async def download_file(
|
|
197
|
-
|
|
198
|
-
description="
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
344
|
+
host: str = Field(
|
|
345
|
+
description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
|
|
346
|
+
),
|
|
347
|
+
user: Optional[str] = Field(
|
|
348
|
+
description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
|
|
349
|
+
),
|
|
350
|
+
password: Optional[str] = Field(
|
|
351
|
+
description="Password.", default=os.environ.get("TUNNEL_PASSWORD", None)
|
|
352
|
+
),
|
|
353
|
+
port: int = Field(
|
|
354
|
+
description="Port.", default=int(os.environ.get("TUNNEL_REMOTE_PORT", 22))
|
|
355
|
+
),
|
|
356
|
+
rpath: str = Field(description="Remote file path.", default=None),
|
|
357
|
+
lpath: str = Field(description="Local path.", default=None),
|
|
358
|
+
id_file: Optional[str] = Field(
|
|
359
|
+
description="Private key path.",
|
|
209
360
|
default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
|
|
210
361
|
),
|
|
211
|
-
|
|
212
|
-
description="
|
|
362
|
+
certificate: Optional[str] = Field(
|
|
363
|
+
description="Teleport certificate.",
|
|
213
364
|
default=os.environ.get("TUNNEL_CERTIFICATE", None),
|
|
214
365
|
),
|
|
215
|
-
|
|
216
|
-
description="
|
|
366
|
+
proxy: Optional[str] = Field(
|
|
367
|
+
description="Teleport proxy.",
|
|
217
368
|
default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
|
|
218
369
|
),
|
|
219
|
-
|
|
220
|
-
description="
|
|
221
|
-
default=os.environ.get("TUNNEL_LOG_FILE", None),
|
|
370
|
+
cfg: str = Field(
|
|
371
|
+
description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
|
|
222
372
|
),
|
|
223
|
-
|
|
224
|
-
description="
|
|
373
|
+
log: Optional[str] = Field(
|
|
374
|
+
description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
|
|
225
375
|
),
|
|
226
|
-
|
|
227
|
-
|
|
376
|
+
ctx: Context = Field(description="MCP context.", default=None),
|
|
377
|
+
) -> Dict:
|
|
378
|
+
"""Download file from remote host. Expected return object type: dict"""
|
|
228
379
|
logger = logging.getLogger("TunnelServer")
|
|
229
|
-
logger
|
|
230
|
-
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
380
|
+
if err := setup_logging(log, logger):
|
|
381
|
+
return err
|
|
382
|
+
logger.debug(f"Download: host={host}, remote={rpath}, local={lpath}")
|
|
383
|
+
if not host or not rpath or not lpath:
|
|
384
|
+
logger.error("Need host, rpath, lpath")
|
|
385
|
+
return ResponseBuilder.build(
|
|
386
|
+
400,
|
|
387
|
+
"Need host, rpath, lpath",
|
|
388
|
+
{"host": host, "rpath": rpath, "lpath": lpath},
|
|
389
|
+
errors=["Need host, rpath, lpath"],
|
|
390
|
+
)
|
|
236
391
|
try:
|
|
237
|
-
|
|
238
|
-
remote_host,
|
|
392
|
+
t = Tunnel(
|
|
393
|
+
remote_host=host,
|
|
394
|
+
username=user,
|
|
395
|
+
password=password,
|
|
396
|
+
port=port,
|
|
397
|
+
identity_file=id_file,
|
|
398
|
+
certificate_file=certificate,
|
|
399
|
+
proxy_command=proxy,
|
|
400
|
+
ssh_config_file=cfg,
|
|
239
401
|
)
|
|
240
|
-
|
|
241
|
-
|
|
402
|
+
t.connect()
|
|
242
403
|
if ctx:
|
|
243
404
|
await ctx.report_progress(progress=0, total=100)
|
|
244
|
-
logger.debug("
|
|
245
|
-
|
|
246
|
-
sftp
|
|
247
|
-
remote_attr = sftp.stat(remote_path)
|
|
248
|
-
file_size = remote_attr.st_size
|
|
405
|
+
logger.debug("Progress: 0/100")
|
|
406
|
+
sftp = t.ssh_client.open_sftp()
|
|
407
|
+
sftp.stat(rpath)
|
|
249
408
|
transferred = 0
|
|
250
409
|
|
|
251
410
|
def progress_callback(transf, total):
|
|
252
411
|
nonlocal transferred
|
|
253
412
|
transferred = transf
|
|
413
|
+
if ctx:
|
|
414
|
+
asyncio.ensure_future(ctx.report_progress(progress=transf, total=total))
|
|
415
|
+
|
|
416
|
+
sftp.get(rpath, lpath, callback=progress_callback)
|
|
417
|
+
if ctx:
|
|
418
|
+
await ctx.report_progress(progress=100, total=100)
|
|
419
|
+
logger.debug("Progress: 100/100")
|
|
420
|
+
sftp.close()
|
|
421
|
+
logger.debug(f"Downloaded: {rpath} -> {lpath}")
|
|
422
|
+
return ResponseBuilder.build(
|
|
423
|
+
200,
|
|
424
|
+
f"Downloaded to {lpath}",
|
|
425
|
+
{"host": host, "rpath": rpath, "lpath": lpath},
|
|
426
|
+
files=[rpath],
|
|
427
|
+
locs=[lpath],
|
|
428
|
+
errors=[],
|
|
429
|
+
)
|
|
430
|
+
except Exception as e:
|
|
431
|
+
logger.error(f"Download fail: {e}")
|
|
432
|
+
return ResponseBuilder.build(
|
|
433
|
+
500,
|
|
434
|
+
f"Download fail: {e}",
|
|
435
|
+
{"host": host, "rpath": rpath, "lpath": lpath},
|
|
436
|
+
str(e),
|
|
437
|
+
)
|
|
438
|
+
finally:
|
|
439
|
+
if "t" in locals():
|
|
440
|
+
t.close()
|
|
254
441
|
|
|
255
|
-
sftp.get(remote_path, local_path, callback=progress_callback)
|
|
256
442
|
|
|
443
|
+
@mcp.tool(
|
|
444
|
+
annotations={
|
|
445
|
+
"title": "Check SSH Server",
|
|
446
|
+
"readOnlyHint": True,
|
|
447
|
+
"destructiveHint": False,
|
|
448
|
+
"idempotentHint": True,
|
|
449
|
+
},
|
|
450
|
+
tags={"remote_access"},
|
|
451
|
+
)
|
|
452
|
+
async def check_ssh_server(
|
|
453
|
+
host: str = Field(
|
|
454
|
+
description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
|
|
455
|
+
),
|
|
456
|
+
user: Optional[str] = Field(
|
|
457
|
+
description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
|
|
458
|
+
),
|
|
459
|
+
password: Optional[str] = Field(
|
|
460
|
+
description="Password.", default=os.environ.get("TUNNEL_PASSWORD", None)
|
|
461
|
+
),
|
|
462
|
+
port: int = Field(
|
|
463
|
+
description="Port.", default=int(os.environ.get("TUNNEL_REMOTE_PORT", 22))
|
|
464
|
+
),
|
|
465
|
+
id_file: Optional[str] = Field(
|
|
466
|
+
description="Private key path.",
|
|
467
|
+
default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
|
|
468
|
+
),
|
|
469
|
+
certificate: Optional[str] = Field(
|
|
470
|
+
description="Teleport certificate.",
|
|
471
|
+
default=os.environ.get("TUNNEL_CERTIFICATE", None),
|
|
472
|
+
),
|
|
473
|
+
proxy: Optional[str] = Field(
|
|
474
|
+
description="Teleport proxy.",
|
|
475
|
+
default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
|
|
476
|
+
),
|
|
477
|
+
cfg: str = Field(
|
|
478
|
+
description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
|
|
479
|
+
),
|
|
480
|
+
log: Optional[str] = Field(
|
|
481
|
+
description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
|
|
482
|
+
),
|
|
483
|
+
ctx: Context = Field(description="MCP context.", default=None),
|
|
484
|
+
) -> Dict:
|
|
485
|
+
"""Check SSH server status. Expected return object type: dict"""
|
|
486
|
+
logger = logging.getLogger("TunnelServer")
|
|
487
|
+
if err := setup_logging(log, logger):
|
|
488
|
+
return err
|
|
489
|
+
logger.debug(f"Check SSH: host={host}")
|
|
490
|
+
if not host:
|
|
491
|
+
logger.error("Need host")
|
|
492
|
+
return ResponseBuilder.build(
|
|
493
|
+
400, "Need host", {"host": host}, errors=["Need host"]
|
|
494
|
+
)
|
|
495
|
+
try:
|
|
496
|
+
t = Tunnel(
|
|
497
|
+
remote_host=host,
|
|
498
|
+
username=user,
|
|
499
|
+
password=password,
|
|
500
|
+
port=port,
|
|
501
|
+
identity_file=id_file,
|
|
502
|
+
certificate_file=certificate,
|
|
503
|
+
proxy_command=proxy,
|
|
504
|
+
ssh_config_file=cfg,
|
|
505
|
+
)
|
|
506
|
+
if ctx:
|
|
507
|
+
await ctx.report_progress(progress=0, total=100)
|
|
508
|
+
logger.debug("Progress: 0/100")
|
|
509
|
+
success, msg = t.check_ssh_server()
|
|
257
510
|
if ctx:
|
|
258
511
|
await ctx.report_progress(progress=100, total=100)
|
|
259
|
-
logger.debug("
|
|
512
|
+
logger.debug("Progress: 100/100")
|
|
513
|
+
logger.debug(f"SSH check: {msg}")
|
|
514
|
+
return ResponseBuilder.build(
|
|
515
|
+
200 if success else 400,
|
|
516
|
+
f"SSH check: {msg}",
|
|
517
|
+
{"host": host, "success": success},
|
|
518
|
+
files=[],
|
|
519
|
+
locs=[],
|
|
520
|
+
errors=[] if success else [msg],
|
|
521
|
+
)
|
|
522
|
+
except Exception as e:
|
|
523
|
+
logger.error(f"Check fail: {e}")
|
|
524
|
+
return ResponseBuilder.build(500, f"Check fail: {e}", {"host": host}, str(e))
|
|
525
|
+
finally:
|
|
526
|
+
if "t" in locals():
|
|
527
|
+
t.close()
|
|
260
528
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
529
|
+
|
|
530
|
+
@mcp.tool(
|
|
531
|
+
annotations={
|
|
532
|
+
"title": "Test Key Authentication",
|
|
533
|
+
"readOnlyHint": True,
|
|
534
|
+
"destructiveHint": False,
|
|
535
|
+
"idempotentHint": True,
|
|
536
|
+
},
|
|
537
|
+
tags={"remote_access"},
|
|
538
|
+
)
|
|
539
|
+
async def test_key_auth(
|
|
540
|
+
host: str = Field(
|
|
541
|
+
description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
|
|
542
|
+
),
|
|
543
|
+
user: Optional[str] = Field(
|
|
544
|
+
description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
|
|
545
|
+
),
|
|
546
|
+
key: str = Field(
|
|
547
|
+
description="Private key path.",
|
|
548
|
+
default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
|
|
549
|
+
),
|
|
550
|
+
port: int = Field(
|
|
551
|
+
description="Port.", default=int(os.environ.get("TUNNEL_REMOTE_PORT", 22))
|
|
552
|
+
),
|
|
553
|
+
cfg: str = Field(
|
|
554
|
+
description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
|
|
555
|
+
),
|
|
556
|
+
log: Optional[str] = Field(
|
|
557
|
+
description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
|
|
558
|
+
),
|
|
559
|
+
ctx: Context = Field(description="MCP context.", default=None),
|
|
560
|
+
) -> Dict:
|
|
561
|
+
"""Test key-based auth. Expected return object type: dict"""
|
|
562
|
+
logger = logging.getLogger("TunnelServer")
|
|
563
|
+
if err := setup_logging(log, logger):
|
|
564
|
+
return err
|
|
565
|
+
logger.debug(f"Test key: host={host}, key={key}")
|
|
566
|
+
if not host or not key:
|
|
567
|
+
logger.error("Need host, key")
|
|
568
|
+
return ResponseBuilder.build(
|
|
569
|
+
400, "Need host, key", {"host": host, "key": key}, errors=["Need host, key"]
|
|
570
|
+
)
|
|
571
|
+
try:
|
|
572
|
+
t = Tunnel(remote_host=host, username=user, port=port, ssh_config_file=cfg)
|
|
573
|
+
if ctx:
|
|
574
|
+
await ctx.report_progress(progress=0, total=100)
|
|
575
|
+
logger.debug("Progress: 0/100")
|
|
576
|
+
success, msg = t.test_key_auth(key)
|
|
577
|
+
if ctx:
|
|
578
|
+
await ctx.report_progress(progress=100, total=100)
|
|
579
|
+
logger.debug("Progress: 100/100")
|
|
580
|
+
logger.debug(f"Key test: {msg}")
|
|
581
|
+
return ResponseBuilder.build(
|
|
582
|
+
200 if success else 400,
|
|
583
|
+
f"Key test: {msg}",
|
|
584
|
+
{"host": host, "key": key, "success": success},
|
|
585
|
+
files=[],
|
|
586
|
+
locs=[],
|
|
587
|
+
errors=[] if success else [msg],
|
|
588
|
+
)
|
|
589
|
+
except Exception as e:
|
|
590
|
+
logger.error(f"Key test fail: {e}")
|
|
591
|
+
return ResponseBuilder.build(
|
|
592
|
+
500, f"Key test fail: {e}", {"host": host, "key": key}, str(e)
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
@mcp.tool(
|
|
597
|
+
annotations={
|
|
598
|
+
"title": "Setup Passwordless SSH",
|
|
599
|
+
"readOnlyHint": False,
|
|
600
|
+
"destructiveHint": True,
|
|
601
|
+
"idempotentHint": False,
|
|
602
|
+
},
|
|
603
|
+
tags={"remote_access"},
|
|
604
|
+
)
|
|
605
|
+
async def setup_passwordless_ssh(
|
|
606
|
+
host: str = Field(
|
|
607
|
+
description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
|
|
608
|
+
),
|
|
609
|
+
user: Optional[str] = Field(
|
|
610
|
+
description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
|
|
611
|
+
),
|
|
612
|
+
password: Optional[str] = Field(
|
|
613
|
+
description="Password.", default=os.environ.get("TUNNEL_PASSWORD", None)
|
|
614
|
+
),
|
|
615
|
+
port: int = Field(
|
|
616
|
+
description="Port.", default=int(os.environ.get("TUNNEL_REMOTE_PORT", 22))
|
|
617
|
+
),
|
|
618
|
+
key: str = Field(
|
|
619
|
+
description="Private key path.", default=os.path.expanduser("~/.ssh/id_rsa")
|
|
620
|
+
),
|
|
621
|
+
cfg: str = Field(
|
|
622
|
+
description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
|
|
623
|
+
),
|
|
624
|
+
log: Optional[str] = Field(
|
|
625
|
+
description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
|
|
626
|
+
),
|
|
627
|
+
ctx: Context = Field(description="MCP context.", default=None),
|
|
628
|
+
) -> Dict:
|
|
629
|
+
"""Setup passwordless SSH. Expected return object type: dict"""
|
|
630
|
+
logger = logging.getLogger("TunnelServer")
|
|
631
|
+
if err := setup_logging(log, logger):
|
|
632
|
+
return err
|
|
633
|
+
logger.debug(f"Setup SSH: host={host}, key={key}")
|
|
634
|
+
if not host or not password:
|
|
635
|
+
logger.error("Need host, password")
|
|
636
|
+
return ResponseBuilder.build(
|
|
637
|
+
400,
|
|
638
|
+
"Need host, password",
|
|
639
|
+
{"host": host, "key": key},
|
|
640
|
+
errors=["Need host, password"],
|
|
641
|
+
)
|
|
642
|
+
try:
|
|
643
|
+
t = Tunnel(
|
|
644
|
+
remote_host=host,
|
|
645
|
+
username=user,
|
|
646
|
+
password=password,
|
|
647
|
+
port=port,
|
|
648
|
+
ssh_config_file=cfg,
|
|
649
|
+
)
|
|
650
|
+
if ctx:
|
|
651
|
+
await ctx.report_progress(progress=0, total=100)
|
|
652
|
+
logger.debug("Progress: 0/100")
|
|
653
|
+
key = os.path.expanduser(key)
|
|
654
|
+
pub_key = key + ".pub"
|
|
655
|
+
if not os.path.exists(pub_key):
|
|
656
|
+
os.system(f"ssh-keygen -t rsa -b 4096 -f {key} -N ''")
|
|
657
|
+
logger.info(f"Gen key: {key}, {pub_key}")
|
|
658
|
+
t.setup_passwordless_ssh(key)
|
|
659
|
+
if ctx:
|
|
660
|
+
await ctx.report_progress(progress=100, total=100)
|
|
661
|
+
logger.debug("Progress: 100/100")
|
|
662
|
+
logger.debug(f"SSH setup for {user}@{host}")
|
|
663
|
+
return ResponseBuilder.build(
|
|
664
|
+
200,
|
|
665
|
+
f"SSH setup for {user}@{host}",
|
|
666
|
+
{"host": host, "key": key, "user": user},
|
|
667
|
+
files=[pub_key],
|
|
668
|
+
locs=[f"~/.ssh/authorized_keys on {host}"],
|
|
669
|
+
errors=[],
|
|
670
|
+
)
|
|
671
|
+
except Exception as e:
|
|
672
|
+
logger.error(f"SSH setup fail: {e}")
|
|
673
|
+
return ResponseBuilder.build(
|
|
674
|
+
500, f"SSH setup fail: {e}", {"host": host, "key": key}, str(e)
|
|
675
|
+
)
|
|
676
|
+
finally:
|
|
677
|
+
if "t" in locals():
|
|
678
|
+
t.close()
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@mcp.tool(
|
|
682
|
+
annotations={
|
|
683
|
+
"title": "Copy SSH Config",
|
|
684
|
+
"readOnlyHint": False,
|
|
685
|
+
"destructiveHint": True,
|
|
686
|
+
"idempotentHint": False,
|
|
687
|
+
},
|
|
688
|
+
tags={"remote_access"},
|
|
689
|
+
)
|
|
690
|
+
async def copy_ssh_config(
|
|
691
|
+
host: str = Field(
|
|
692
|
+
description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
|
|
693
|
+
),
|
|
694
|
+
user: Optional[str] = Field(
|
|
695
|
+
description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
|
|
696
|
+
),
|
|
697
|
+
password: Optional[str] = Field(
|
|
698
|
+
description="Password.", default=os.environ.get("TUNNEL_PASSWORD", None)
|
|
699
|
+
),
|
|
700
|
+
port: int = Field(
|
|
701
|
+
description="Port.", default=int(os.environ.get("TUNNEL_REMOTE_PORT", 22))
|
|
702
|
+
),
|
|
703
|
+
lcfg: str = Field(description="Local SSH config.", default=None),
|
|
704
|
+
rcfg: str = Field(
|
|
705
|
+
description="Remote SSH config.", default=os.path.expanduser("~/.ssh/config")
|
|
706
|
+
),
|
|
707
|
+
id_file: Optional[str] = Field(
|
|
708
|
+
description="Private key path.",
|
|
709
|
+
default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
|
|
710
|
+
),
|
|
711
|
+
certificate: Optional[str] = Field(
|
|
712
|
+
description="Teleport certificate.",
|
|
713
|
+
default=os.environ.get("TUNNEL_CERTIFICATE", None),
|
|
714
|
+
),
|
|
715
|
+
proxy: Optional[str] = Field(
|
|
716
|
+
description="Teleport proxy.",
|
|
717
|
+
default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
|
|
718
|
+
),
|
|
719
|
+
cfg: str = Field(
|
|
720
|
+
description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
|
|
721
|
+
),
|
|
722
|
+
log: Optional[str] = Field(
|
|
723
|
+
description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
|
|
724
|
+
),
|
|
725
|
+
ctx: Context = Field(description="MCP context.", default=None),
|
|
726
|
+
) -> Dict:
|
|
727
|
+
"""Copy SSH config to remote host. Expected return object type: dict"""
|
|
728
|
+
logger = logging.getLogger("TunnelServer")
|
|
729
|
+
if err := setup_logging(log, logger):
|
|
730
|
+
return err
|
|
731
|
+
logger.debug(f"Copy cfg: host={host}, local={lcfg}, remote={rcfg}")
|
|
732
|
+
if not host or not lcfg:
|
|
733
|
+
logger.error("Need host, lcfg")
|
|
734
|
+
return ResponseBuilder.build(
|
|
735
|
+
400,
|
|
736
|
+
"Need host, lcfg",
|
|
737
|
+
{"host": host, "lcfg": lcfg, "rcfg": rcfg},
|
|
738
|
+
errors=["Need host, lcfg"],
|
|
739
|
+
)
|
|
740
|
+
try:
|
|
741
|
+
t = Tunnel(
|
|
742
|
+
remote_host=host,
|
|
743
|
+
username=user,
|
|
744
|
+
password=password,
|
|
745
|
+
port=port,
|
|
746
|
+
identity_file=id_file,
|
|
747
|
+
certificate_file=certificate,
|
|
748
|
+
proxy_command=proxy,
|
|
749
|
+
ssh_config_file=cfg,
|
|
750
|
+
)
|
|
751
|
+
if ctx:
|
|
752
|
+
await ctx.report_progress(progress=0, total=100)
|
|
753
|
+
logger.debug("Progress: 0/100")
|
|
754
|
+
t.copy_ssh_config(lcfg, rcfg)
|
|
755
|
+
if ctx:
|
|
756
|
+
await ctx.report_progress(progress=100, total=100)
|
|
757
|
+
logger.debug("Progress: 100/100")
|
|
758
|
+
logger.debug(f"Copied cfg to {rcfg} on {host}")
|
|
759
|
+
return ResponseBuilder.build(
|
|
760
|
+
200,
|
|
761
|
+
f"Copied cfg to {rcfg} on {host}",
|
|
762
|
+
{"host": host, "lcfg": lcfg, "rcfg": rcfg},
|
|
763
|
+
files=[lcfg],
|
|
764
|
+
locs=[rcfg],
|
|
765
|
+
errors=[],
|
|
766
|
+
)
|
|
264
767
|
except Exception as e:
|
|
265
|
-
logger.error(f"
|
|
266
|
-
|
|
768
|
+
logger.error(f"Copy cfg fail: {e}")
|
|
769
|
+
return ResponseBuilder.build(
|
|
770
|
+
500,
|
|
771
|
+
f"Copy cfg fail: {e}",
|
|
772
|
+
{"host": host, "lcfg": lcfg, "rcfg": rcfg},
|
|
773
|
+
str(e),
|
|
774
|
+
)
|
|
267
775
|
finally:
|
|
268
|
-
if "
|
|
269
|
-
|
|
776
|
+
if "t" in locals():
|
|
777
|
+
t.close()
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
@mcp.tool(
|
|
781
|
+
annotations={
|
|
782
|
+
"title": "Rotate SSH Key",
|
|
783
|
+
"readOnlyHint": False,
|
|
784
|
+
"destructiveHint": True,
|
|
785
|
+
"idempotentHint": False,
|
|
786
|
+
},
|
|
787
|
+
tags={"remote_access"},
|
|
788
|
+
)
|
|
789
|
+
async def rotate_ssh_key(
|
|
790
|
+
host: str = Field(
|
|
791
|
+
description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
|
|
792
|
+
),
|
|
793
|
+
user: Optional[str] = Field(
|
|
794
|
+
description="Username.", default=os.environ.get("TUNNEL_USERNAME", None)
|
|
795
|
+
),
|
|
796
|
+
password: Optional[str] = Field(
|
|
797
|
+
description="Password.", default=os.environ.get("TUNNEL_PASSWORD", None)
|
|
798
|
+
),
|
|
799
|
+
port: int = Field(
|
|
800
|
+
description="Port.", default=int(os.environ.get("TUNNEL_REMOTE_PORT", 22))
|
|
801
|
+
),
|
|
802
|
+
new_key: str = Field(description="New private key path.", default=None),
|
|
803
|
+
id_file: Optional[str] = Field(
|
|
804
|
+
description="Current key path.",
|
|
805
|
+
default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
|
|
806
|
+
),
|
|
807
|
+
certificate: Optional[str] = Field(
|
|
808
|
+
description="Teleport certificate.",
|
|
809
|
+
default=os.environ.get("TUNNEL_CERTIFICATE", None),
|
|
810
|
+
),
|
|
811
|
+
proxy: Optional[str] = Field(
|
|
812
|
+
description="Teleport proxy.",
|
|
813
|
+
default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
|
|
814
|
+
),
|
|
815
|
+
cfg: str = Field(
|
|
816
|
+
description="SSH config path.", default=os.path.expanduser("~/.ssh/config")
|
|
817
|
+
),
|
|
818
|
+
log: Optional[str] = Field(
|
|
819
|
+
description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
|
|
820
|
+
),
|
|
821
|
+
ctx: Context = Field(description="MCP context.", default=None),
|
|
822
|
+
) -> Dict:
|
|
823
|
+
"""Rotate SSH key on remote host. Expected return object type: dict"""
|
|
824
|
+
logger = logging.getLogger("TunnelServer")
|
|
825
|
+
if err := setup_logging(log, logger):
|
|
826
|
+
return err
|
|
827
|
+
logger.debug(f"Rotate key: host={host}, new_key={new_key}")
|
|
828
|
+
if not host or not new_key:
|
|
829
|
+
logger.error("Need host, new_key")
|
|
830
|
+
return ResponseBuilder.build(
|
|
831
|
+
400,
|
|
832
|
+
"Need host, new_key",
|
|
833
|
+
{"host": host, "new_key": new_key},
|
|
834
|
+
errors=["Need host, new_key"],
|
|
835
|
+
)
|
|
836
|
+
try:
|
|
837
|
+
t = Tunnel(
|
|
838
|
+
remote_host=host,
|
|
839
|
+
username=user,
|
|
840
|
+
password=password,
|
|
841
|
+
port=port,
|
|
842
|
+
identity_file=id_file,
|
|
843
|
+
certificate_file=certificate,
|
|
844
|
+
proxy_command=proxy,
|
|
845
|
+
ssh_config_file=cfg,
|
|
846
|
+
)
|
|
847
|
+
if ctx:
|
|
848
|
+
await ctx.report_progress(progress=0, total=100)
|
|
849
|
+
logger.debug("Progress: 0/100")
|
|
850
|
+
new_key = os.path.expanduser(new_key)
|
|
851
|
+
new_public_key = new_key + ".pub"
|
|
852
|
+
if not os.path.exists(new_key):
|
|
853
|
+
os.system(f"ssh-keygen -t rsa -b 4096 -f {new_key} -N ''")
|
|
854
|
+
logger.info(f"Gen key: {new_key}")
|
|
855
|
+
t.rotate_ssh_key(new_key)
|
|
856
|
+
if ctx:
|
|
857
|
+
await ctx.report_progress(progress=100, total=100)
|
|
858
|
+
logger.debug("Progress: 100/100")
|
|
859
|
+
logger.debug(f"Rotated key to {new_key} on {host}")
|
|
860
|
+
return ResponseBuilder.build(
|
|
861
|
+
200,
|
|
862
|
+
f"Rotated key to {new_key} on {host}",
|
|
863
|
+
{"host": host, "new_key": new_key, "old_key": id_file},
|
|
864
|
+
files=[new_public_key],
|
|
865
|
+
locs=[f"~/.ssh/authorized_keys on {host}"],
|
|
866
|
+
errors=[],
|
|
867
|
+
)
|
|
868
|
+
except Exception as e:
|
|
869
|
+
logger.error(f"Rotate fail: {e}")
|
|
870
|
+
return ResponseBuilder.build(
|
|
871
|
+
500, f"Rotate fail: {e}", {"host": host, "new_key": new_key}, str(e)
|
|
872
|
+
)
|
|
873
|
+
finally:
|
|
874
|
+
if "t" in locals():
|
|
875
|
+
t.close()
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
@mcp.tool(
|
|
879
|
+
annotations={
|
|
880
|
+
"title": "Remove Host Key",
|
|
881
|
+
"readOnlyHint": False,
|
|
882
|
+
"destructiveHint": True,
|
|
883
|
+
"idempotentHint": True,
|
|
884
|
+
},
|
|
885
|
+
tags={"remote_access"},
|
|
886
|
+
)
|
|
887
|
+
async def remove_host_key(
|
|
888
|
+
host: str = Field(
|
|
889
|
+
description="Remote host.", default=os.environ.get("TUNNEL_REMOTE_HOST", None)
|
|
890
|
+
),
|
|
891
|
+
known_hosts: str = Field(
|
|
892
|
+
description="Known hosts path.",
|
|
893
|
+
default=os.path.expanduser("~/.ssh/known_hosts"),
|
|
894
|
+
),
|
|
895
|
+
log: Optional[str] = Field(
|
|
896
|
+
description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
|
|
897
|
+
),
|
|
898
|
+
ctx: Context = Field(description="MCP context.", default=None),
|
|
899
|
+
) -> Dict:
|
|
900
|
+
"""Remove host key from known_hosts. Expected return object type: dict"""
|
|
901
|
+
logger = logging.getLogger("TunnelServer")
|
|
902
|
+
if err := setup_logging(log, logger):
|
|
903
|
+
return err
|
|
904
|
+
logger.debug(f"Remove key: host={host}, known_hosts={known_hosts}")
|
|
905
|
+
if not host:
|
|
906
|
+
logger.error("Need host")
|
|
907
|
+
return ResponseBuilder.build(
|
|
908
|
+
400,
|
|
909
|
+
"Need host",
|
|
910
|
+
{"host": host, "known_hosts": known_hosts},
|
|
911
|
+
errors=["Need host"],
|
|
912
|
+
)
|
|
913
|
+
try:
|
|
914
|
+
t = Tunnel(remote_host=host)
|
|
915
|
+
if ctx:
|
|
916
|
+
await ctx.report_progress(progress=0, total=100)
|
|
917
|
+
logger.debug("Progress: 0/100")
|
|
918
|
+
known_hosts = os.path.expanduser(known_hosts)
|
|
919
|
+
msg = t.remove_host_key(known_hosts_path=known_hosts)
|
|
920
|
+
if ctx:
|
|
921
|
+
await ctx.report_progress(progress=100, total=100)
|
|
922
|
+
logger.debug("Progress: 100/100")
|
|
923
|
+
logger.debug(f"Remove result: {msg}")
|
|
924
|
+
return ResponseBuilder.build(
|
|
925
|
+
200 if "Removed" in msg else 400,
|
|
926
|
+
msg,
|
|
927
|
+
{"host": host, "known_hosts": known_hosts},
|
|
928
|
+
files=[],
|
|
929
|
+
locs=[],
|
|
930
|
+
errors=[] if "Removed" in msg else [msg],
|
|
931
|
+
)
|
|
932
|
+
except Exception as e:
|
|
933
|
+
logger.error(f"Remove fail: {e}")
|
|
934
|
+
return ResponseBuilder.build(
|
|
935
|
+
500, f"Remove fail: {e}", {"host": host, "known_hosts": known_hosts}, str(e)
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
@mcp.tool(
|
|
940
|
+
annotations={
|
|
941
|
+
"title": "Setup Passwordless SSH for All",
|
|
942
|
+
"readOnlyHint": False,
|
|
943
|
+
"destructiveHint": True,
|
|
944
|
+
"idempotentHint": False,
|
|
945
|
+
},
|
|
946
|
+
tags={"remote_access"},
|
|
947
|
+
)
|
|
948
|
+
async def setup_all_passwordless_ssh(
|
|
949
|
+
inventory_path: str = Field(
|
|
950
|
+
description="YAML inventory path.",
|
|
951
|
+
default=os.environ.get("TUNNEL_INVENTORY", None),
|
|
952
|
+
),
|
|
953
|
+
key: str = Field(
|
|
954
|
+
description="Shared key path.",
|
|
955
|
+
default=os.environ.get(
|
|
956
|
+
"TUNNEL_IDENTITY_FILE", os.path.expanduser("~/.ssh/id_shared")
|
|
957
|
+
),
|
|
958
|
+
),
|
|
959
|
+
group: str = Field(
|
|
960
|
+
description="Target group.",
|
|
961
|
+
default=os.environ.get("TUNNEL_INVENTORY_GROUP", "all"),
|
|
962
|
+
),
|
|
963
|
+
parallel: bool = Field(
|
|
964
|
+
description="Run parallel.",
|
|
965
|
+
default=to_boolean(os.environ.get("TUNNEL_PARALLEL", False)),
|
|
966
|
+
),
|
|
967
|
+
max_threads: int = Field(
|
|
968
|
+
description="Max threads.", default=int(os.environ.get("TUNNEL_MAX_THREADS", 5))
|
|
969
|
+
),
|
|
970
|
+
log: Optional[str] = Field(description="Log file.", default=None),
|
|
971
|
+
ctx: Context = Field(description="MCP context.", default=None),
|
|
972
|
+
) -> Dict:
|
|
973
|
+
"""Setup passwordless SSH for all hosts in group. Expected return object type: dict"""
|
|
974
|
+
logger = logging.getLogger("TunnelServer")
|
|
975
|
+
if err := setup_logging(log, logger):
|
|
976
|
+
return err
|
|
977
|
+
logger.debug(f"Setup SSH all: inv={inventory_path}, group={group}")
|
|
978
|
+
if not inventory_path:
|
|
979
|
+
logger.error("Need inventory_path")
|
|
980
|
+
return ResponseBuilder.build(
|
|
981
|
+
400,
|
|
982
|
+
"Need inventory_path",
|
|
983
|
+
{"inventory_path": inventory_path, "group": group},
|
|
984
|
+
errors=["Need inventory_path"],
|
|
985
|
+
)
|
|
986
|
+
try:
|
|
987
|
+
key = os.path.expanduser(key)
|
|
988
|
+
pub_key = key + ".pub"
|
|
989
|
+
if not os.path.exists(key):
|
|
990
|
+
os.system(f"ssh-keygen -t rsa -b 4096 -f {key} -N ''")
|
|
991
|
+
logger.info(f"Gen key: {key}, {pub_key}")
|
|
992
|
+
with open(pub_key, "r") as f:
|
|
993
|
+
pub = f.read().strip()
|
|
994
|
+
hosts, err = load_inventory(inventory_path, group, logger)
|
|
995
|
+
if err:
|
|
996
|
+
return err
|
|
997
|
+
total = len(hosts)
|
|
998
|
+
if ctx:
|
|
999
|
+
await ctx.report_progress(progress=0, total=total)
|
|
1000
|
+
logger.debug(f"Progress: 0/{total}")
|
|
1001
|
+
|
|
1002
|
+
async def setup_host(h: Dict, ctx: Context) -> Dict:
|
|
1003
|
+
host, user, password = h["hostname"], h["username"], h["password"]
|
|
1004
|
+
kpath = h.get("key_path", key)
|
|
1005
|
+
logger.info(f"Setup {user}@{host}")
|
|
1006
|
+
try:
|
|
1007
|
+
t = Tunnel(remote_host=host, username=user, password=password)
|
|
1008
|
+
t.remove_host_key()
|
|
1009
|
+
t.setup_passwordless_ssh(local_key_path=kpath)
|
|
1010
|
+
t.connect()
|
|
1011
|
+
t.run_command(f"echo '{pub}' >> ~/.ssh/authorized_keys")
|
|
1012
|
+
t.run_command("chmod 600 ~/.ssh/authorized_keys")
|
|
1013
|
+
logger.info(f"Added key to {user}@{host}")
|
|
1014
|
+
res, msg = t.test_key_auth(key)
|
|
1015
|
+
return {
|
|
1016
|
+
"hostname": host,
|
|
1017
|
+
"status": "success",
|
|
1018
|
+
"message": f"SSH setup for {user}@{host}",
|
|
1019
|
+
"errors": [] if res else [msg],
|
|
1020
|
+
}
|
|
1021
|
+
except Exception as e:
|
|
1022
|
+
logger.error(f"Setup fail {user}@{host}: {e}")
|
|
1023
|
+
return {
|
|
1024
|
+
"hostname": host,
|
|
1025
|
+
"status": "failed",
|
|
1026
|
+
"message": f"Setup fail: {e}",
|
|
1027
|
+
"errors": [str(e)],
|
|
1028
|
+
}
|
|
1029
|
+
finally:
|
|
1030
|
+
if "t" in locals():
|
|
1031
|
+
t.close()
|
|
1032
|
+
|
|
1033
|
+
results, files, locs, errors = [], [], [], []
|
|
1034
|
+
if parallel:
|
|
1035
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as ex:
|
|
1036
|
+
futures = [
|
|
1037
|
+
ex.submit(lambda h: asyncio.run(setup_host(h, ctx)), h)
|
|
1038
|
+
for h in hosts
|
|
1039
|
+
]
|
|
1040
|
+
for i, f in enumerate(concurrent.futures.as_completed(futures), 1):
|
|
1041
|
+
try:
|
|
1042
|
+
r = f.result()
|
|
1043
|
+
results.append(r)
|
|
1044
|
+
if r["status"] == "success":
|
|
1045
|
+
files.append(pub_key)
|
|
1046
|
+
locs.append(f"~/.ssh/authorized_keys on {r['hostname']}")
|
|
1047
|
+
else:
|
|
1048
|
+
errors.extend(r["errors"])
|
|
1049
|
+
if ctx:
|
|
1050
|
+
await ctx.report_progress(progress=i, total=total)
|
|
1051
|
+
logger.debug(f"Progress: {i}/{total}")
|
|
1052
|
+
except Exception as e:
|
|
1053
|
+
logger.error(f"Parallel err: {e}")
|
|
1054
|
+
results.append(
|
|
1055
|
+
{
|
|
1056
|
+
"hostname": "unknown",
|
|
1057
|
+
"status": "failed",
|
|
1058
|
+
"message": f"Parallel err: {e}",
|
|
1059
|
+
"errors": [str(e)],
|
|
1060
|
+
}
|
|
1061
|
+
)
|
|
1062
|
+
errors.append(str(e))
|
|
1063
|
+
else:
|
|
1064
|
+
for i, h in enumerate(hosts, 1):
|
|
1065
|
+
r = await setup_host(h, ctx)
|
|
1066
|
+
results.append(r)
|
|
1067
|
+
if r["status"] == "success":
|
|
1068
|
+
files.append(pub_key)
|
|
1069
|
+
locs.append(f"~/.ssh/authorized_keys on {r['hostname']}")
|
|
1070
|
+
else:
|
|
1071
|
+
errors.extend(r["errors"])
|
|
1072
|
+
if ctx:
|
|
1073
|
+
await ctx.report_progress(progress=i, total=total)
|
|
1074
|
+
logger.debug(f"Progress: {i}/{total}")
|
|
1075
|
+
logger.debug(f"Done SSH setup for {group}")
|
|
1076
|
+
msg = (
|
|
1077
|
+
f"SSH setup done for {group}"
|
|
1078
|
+
if not errors
|
|
1079
|
+
else f"SSH setup failed for some in {group}"
|
|
1080
|
+
)
|
|
1081
|
+
return ResponseBuilder.build(
|
|
1082
|
+
200 if not errors else 500,
|
|
1083
|
+
msg,
|
|
1084
|
+
{"inventory_path": inventory_path, "group": group, "host_results": results},
|
|
1085
|
+
"; ".join(errors),
|
|
1086
|
+
files,
|
|
1087
|
+
locs,
|
|
1088
|
+
errors,
|
|
1089
|
+
)
|
|
1090
|
+
except Exception as e:
|
|
1091
|
+
logger.error(f"Setup all fail: {e}")
|
|
1092
|
+
return ResponseBuilder.build(
|
|
1093
|
+
500,
|
|
1094
|
+
f"Setup all fail: {e}",
|
|
1095
|
+
{"inventory_path": inventory_path, "group": group},
|
|
1096
|
+
str(e),
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
@mcp.tool(
|
|
1101
|
+
annotations={
|
|
1102
|
+
"title": "Run Command on All Hosts",
|
|
1103
|
+
"readOnlyHint": True,
|
|
1104
|
+
"destructiveHint": True,
|
|
1105
|
+
"idempotentHint": False,
|
|
1106
|
+
},
|
|
1107
|
+
tags={"remote_access"},
|
|
1108
|
+
)
|
|
1109
|
+
async def run_command_on_all(
|
|
1110
|
+
inventory_path: str = Field(
|
|
1111
|
+
description="YAML inventory path.",
|
|
1112
|
+
default=os.environ.get("TUNNEL_INVENTORY", None),
|
|
1113
|
+
),
|
|
1114
|
+
cmd: str = Field(description="Shell command.", default=None),
|
|
1115
|
+
group: str = Field(
|
|
1116
|
+
description="Target group.",
|
|
1117
|
+
default=os.environ.get("TUNNEL_INVENTORY_GROUP", "all"),
|
|
1118
|
+
),
|
|
1119
|
+
parallel: bool = Field(
|
|
1120
|
+
description="Run parallel.",
|
|
1121
|
+
default=to_boolean(os.environ.get("TUNNEL_PARALLEL", False)),
|
|
1122
|
+
),
|
|
1123
|
+
max_threads: int = Field(
|
|
1124
|
+
description="Max threads.", default=int(os.environ.get("TUNNEL_MAX_THREADS", 5))
|
|
1125
|
+
),
|
|
1126
|
+
log: Optional[str] = Field(description="Log file.", default=None),
|
|
1127
|
+
ctx: Context = Field(description="MCP context.", default=None),
|
|
1128
|
+
) -> Dict:
|
|
1129
|
+
"""Run command on all hosts in group. Expected return object type: dict"""
|
|
1130
|
+
logger = logging.getLogger("TunnelServer")
|
|
1131
|
+
if err := setup_logging(log, logger):
|
|
1132
|
+
return err
|
|
1133
|
+
logger.debug(f"Run cmd all: inv={inventory_path}, group={group}, cmd={cmd}")
|
|
1134
|
+
if not inventory_path or not cmd:
|
|
1135
|
+
logger.error("Need inventory_path, cmd")
|
|
1136
|
+
return ResponseBuilder.build(
|
|
1137
|
+
400,
|
|
1138
|
+
"Need inventory_path, cmd",
|
|
1139
|
+
{"inventory_path": inventory_path, "group": group, "cmd": cmd},
|
|
1140
|
+
errors=["Need inventory_path, cmd"],
|
|
1141
|
+
)
|
|
1142
|
+
try:
|
|
1143
|
+
hosts, err = load_inventory(inventory_path, group, logger)
|
|
1144
|
+
if err:
|
|
1145
|
+
return err
|
|
1146
|
+
total = len(hosts)
|
|
1147
|
+
if ctx:
|
|
1148
|
+
await ctx.report_progress(progress=0, total=total)
|
|
1149
|
+
logger.debug(f"Progress: 0/{total}")
|
|
1150
|
+
|
|
1151
|
+
async def run_host(h: Dict, ctx: Context) -> Dict:
|
|
1152
|
+
host = h["hostname"]
|
|
1153
|
+
try:
|
|
1154
|
+
t = Tunnel(
|
|
1155
|
+
remote_host=host,
|
|
1156
|
+
username=h["username"],
|
|
1157
|
+
password=h.get("password"),
|
|
1158
|
+
identity_file=h.get("key_path"),
|
|
1159
|
+
)
|
|
1160
|
+
out, err = t.run_command(cmd)
|
|
1161
|
+
logger.info(f"Host {host}: Out: {out}, Err: {err}")
|
|
1162
|
+
return {
|
|
1163
|
+
"hostname": host,
|
|
1164
|
+
"status": "success",
|
|
1165
|
+
"message": f"Cmd '{cmd}' done on {host}",
|
|
1166
|
+
"stdout": out,
|
|
1167
|
+
"stderr": err,
|
|
1168
|
+
"errors": [],
|
|
1169
|
+
}
|
|
1170
|
+
except Exception as e:
|
|
1171
|
+
logger.error(f"Cmd fail {host}: {e}")
|
|
1172
|
+
return {
|
|
1173
|
+
"hostname": host,
|
|
1174
|
+
"status": "failed",
|
|
1175
|
+
"message": f"Cmd fail: {e}",
|
|
1176
|
+
"stdout": "",
|
|
1177
|
+
"stderr": str(e),
|
|
1178
|
+
"errors": [str(e)],
|
|
1179
|
+
}
|
|
1180
|
+
finally:
|
|
1181
|
+
if "t" in locals():
|
|
1182
|
+
t.close()
|
|
1183
|
+
|
|
1184
|
+
results, errors = [], []
|
|
1185
|
+
if parallel:
|
|
1186
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as ex:
|
|
1187
|
+
futures = [
|
|
1188
|
+
ex.submit(lambda h: asyncio.run(run_host(h, ctx)), h) for h in hosts
|
|
1189
|
+
]
|
|
1190
|
+
for i, f in enumerate(concurrent.futures.as_completed(futures), 1):
|
|
1191
|
+
try:
|
|
1192
|
+
r = f.result()
|
|
1193
|
+
results.append(r)
|
|
1194
|
+
errors.extend(r["errors"])
|
|
1195
|
+
if ctx:
|
|
1196
|
+
await ctx.report_progress(progress=i, total=total)
|
|
1197
|
+
logger.debug(f"Progress: {i}/{total}")
|
|
1198
|
+
except Exception as e:
|
|
1199
|
+
logger.error(f"Parallel err: {e}")
|
|
1200
|
+
results.append(
|
|
1201
|
+
{
|
|
1202
|
+
"hostname": "unknown",
|
|
1203
|
+
"status": "failed",
|
|
1204
|
+
"message": f"Parallel err: {e}",
|
|
1205
|
+
"stdout": "",
|
|
1206
|
+
"stderr": str(e),
|
|
1207
|
+
"errors": [str(e)],
|
|
1208
|
+
}
|
|
1209
|
+
)
|
|
1210
|
+
errors.append(str(e))
|
|
1211
|
+
else:
|
|
1212
|
+
for i, h in enumerate(hosts, 1):
|
|
1213
|
+
r = await run_host(h, ctx)
|
|
1214
|
+
results.append(r)
|
|
1215
|
+
errors.extend(r["errors"])
|
|
1216
|
+
if ctx:
|
|
1217
|
+
await ctx.report_progress(progress=i, total=total)
|
|
1218
|
+
logger.debug(f"Progress: {i}/{total}")
|
|
1219
|
+
logger.debug(f"Done cmd for {group}")
|
|
1220
|
+
msg = (
|
|
1221
|
+
f"Cmd '{cmd}' done on {group}"
|
|
1222
|
+
if not errors
|
|
1223
|
+
else f"Cmd '{cmd}' failed for some in {group}"
|
|
1224
|
+
)
|
|
1225
|
+
return ResponseBuilder.build(
|
|
1226
|
+
200 if not errors else 500,
|
|
1227
|
+
msg,
|
|
1228
|
+
{
|
|
1229
|
+
"inventory_path": inventory_path,
|
|
1230
|
+
"group": group,
|
|
1231
|
+
"cmd": cmd,
|
|
1232
|
+
"host_results": results,
|
|
1233
|
+
},
|
|
1234
|
+
"; ".join(errors),
|
|
1235
|
+
[],
|
|
1236
|
+
[],
|
|
1237
|
+
errors,
|
|
1238
|
+
)
|
|
1239
|
+
except Exception as e:
|
|
1240
|
+
logger.error(f"Cmd all fail: {e}")
|
|
1241
|
+
return ResponseBuilder.build(
|
|
1242
|
+
500,
|
|
1243
|
+
f"Cmd all fail: {e}",
|
|
1244
|
+
{"inventory_path": inventory_path, "group": group, "cmd": cmd},
|
|
1245
|
+
str(e),
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
@mcp.tool(
|
|
1250
|
+
annotations={
|
|
1251
|
+
"title": "Copy SSH Config to All",
|
|
1252
|
+
"readOnlyHint": False,
|
|
1253
|
+
"destructiveHint": True,
|
|
1254
|
+
"idempotentHint": False,
|
|
1255
|
+
},
|
|
1256
|
+
tags={"remote_access"},
|
|
1257
|
+
)
|
|
1258
|
+
async def copy_ssh_config_on_all(
|
|
1259
|
+
inventory_path: str = Field(
|
|
1260
|
+
description="YAML inventory path.",
|
|
1261
|
+
default=os.environ.get("TUNNEL_INVENTORY", None),
|
|
1262
|
+
),
|
|
1263
|
+
cfg: str = Field(description="Local SSH config path.", default=None),
|
|
1264
|
+
rmt_cfg: str = Field(
|
|
1265
|
+
description="Remote path.", default=os.path.expanduser("~/.ssh/config")
|
|
1266
|
+
),
|
|
1267
|
+
group: str = Field(
|
|
1268
|
+
description="Target group.",
|
|
1269
|
+
default=os.environ.get("TUNNEL_INVENTORY_GROUP", "all"),
|
|
1270
|
+
),
|
|
1271
|
+
parallel: bool = Field(
|
|
1272
|
+
description="Run parallel.",
|
|
1273
|
+
default=to_boolean(os.environ.get("TUNNEL_PARALLEL", False)),
|
|
1274
|
+
),
|
|
1275
|
+
max_threads: int = Field(
|
|
1276
|
+
description="Max threads.", default=int(os.environ.get("TUNNEL_MAX_THREADS", 5))
|
|
1277
|
+
),
|
|
1278
|
+
log: Optional[str] = Field(
|
|
1279
|
+
description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
|
|
1280
|
+
),
|
|
1281
|
+
ctx: Context = Field(description="MCP context.", default=None),
|
|
1282
|
+
) -> Dict:
|
|
1283
|
+
"""Copy SSH config to all hosts in YAML group. Expected return object type: dict"""
|
|
1284
|
+
logger = logging.getLogger("TunnelServer")
|
|
1285
|
+
if err := setup_logging(log, logger):
|
|
1286
|
+
return err
|
|
1287
|
+
logger.debug(f"Copy SSH config: inv={inventory_path}, group={group}")
|
|
1288
|
+
|
|
1289
|
+
if not inventory_path or not cfg:
|
|
1290
|
+
logger.error("Need inventory_path, cfg")
|
|
1291
|
+
return ResponseBuilder.build(
|
|
1292
|
+
400,
|
|
1293
|
+
"Need inventory_path, cfg",
|
|
1294
|
+
{
|
|
1295
|
+
"inventory_path": inventory_path,
|
|
1296
|
+
"group": group,
|
|
1297
|
+
"cfg": cfg,
|
|
1298
|
+
"rmt_cfg": rmt_cfg,
|
|
1299
|
+
},
|
|
1300
|
+
errors=["Need inventory_path, cfg"],
|
|
1301
|
+
)
|
|
1302
|
+
|
|
1303
|
+
if not os.path.exists(cfg):
|
|
1304
|
+
logger.error(f"No cfg file: {cfg}")
|
|
1305
|
+
return ResponseBuilder.build(
|
|
1306
|
+
400,
|
|
1307
|
+
f"No cfg file: {cfg}",
|
|
1308
|
+
{
|
|
1309
|
+
"inventory_path": inventory_path,
|
|
1310
|
+
"group": group,
|
|
1311
|
+
"cfg": cfg,
|
|
1312
|
+
"rmt_cfg": rmt_cfg,
|
|
1313
|
+
},
|
|
1314
|
+
errors=[f"No cfg file: {cfg}"],
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
try:
|
|
1318
|
+
hosts, err = load_inventory(inventory_path, group, logger)
|
|
1319
|
+
if err:
|
|
1320
|
+
return err
|
|
1321
|
+
|
|
1322
|
+
total = len(hosts)
|
|
1323
|
+
if ctx:
|
|
1324
|
+
await ctx.report_progress(progress=0, total=total)
|
|
1325
|
+
logger.debug(f"Progress: 0/{total}")
|
|
1326
|
+
|
|
1327
|
+
results, files, locs, errors = [], [], [], []
|
|
1328
|
+
|
|
1329
|
+
async def copy_host(h: Dict) -> Dict:
|
|
1330
|
+
try:
|
|
1331
|
+
t = Tunnel(
|
|
1332
|
+
remote_host=h["hostname"],
|
|
1333
|
+
username=h["username"],
|
|
1334
|
+
password=h.get("password"),
|
|
1335
|
+
identity_file=h.get("key_path"),
|
|
1336
|
+
)
|
|
1337
|
+
t.copy_ssh_config(cfg, rmt_cfg)
|
|
1338
|
+
logger.info(f"Copied cfg to {rmt_cfg} on {h['hostname']}")
|
|
1339
|
+
return {
|
|
1340
|
+
"hostname": h["hostname"],
|
|
1341
|
+
"status": "success",
|
|
1342
|
+
"message": f"Copied cfg to {rmt_cfg}",
|
|
1343
|
+
"errors": [],
|
|
1344
|
+
}
|
|
1345
|
+
except Exception as e:
|
|
1346
|
+
logger.error(f"Copy fail {h['hostname']}: {e}")
|
|
1347
|
+
return {
|
|
1348
|
+
"hostname": h["hostname"],
|
|
1349
|
+
"status": "failed",
|
|
1350
|
+
"message": f"Copy fail: {e}",
|
|
1351
|
+
"errors": [str(e)],
|
|
1352
|
+
}
|
|
1353
|
+
finally:
|
|
1354
|
+
if "t" in locals():
|
|
1355
|
+
t.close()
|
|
1356
|
+
|
|
1357
|
+
if parallel:
|
|
1358
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as ex:
|
|
1359
|
+
futures = [
|
|
1360
|
+
ex.submit(lambda h: asyncio.run(copy_host(h)), h) for h in hosts
|
|
1361
|
+
]
|
|
1362
|
+
for i, f in enumerate(concurrent.futures.as_completed(futures), 1):
|
|
1363
|
+
try:
|
|
1364
|
+
r = f.result()
|
|
1365
|
+
results.append(r)
|
|
1366
|
+
if r["status"] == "success":
|
|
1367
|
+
files.append(cfg)
|
|
1368
|
+
locs.append(f"{rmt_cfg} on {r['hostname']}")
|
|
1369
|
+
else:
|
|
1370
|
+
errors.extend(r["errors"])
|
|
1371
|
+
if ctx:
|
|
1372
|
+
await ctx.report_progress(progress=i, total=total)
|
|
1373
|
+
logger.debug(f"Progress: {i}/{total}")
|
|
1374
|
+
except Exception as e:
|
|
1375
|
+
logger.error(f"Parallel err: {e}")
|
|
1376
|
+
results.append(
|
|
1377
|
+
{
|
|
1378
|
+
"hostname": "unknown",
|
|
1379
|
+
"status": "failed",
|
|
1380
|
+
"message": f"Parallel err: {e}",
|
|
1381
|
+
"errors": [str(e)],
|
|
1382
|
+
}
|
|
1383
|
+
)
|
|
1384
|
+
errors.append(str(e))
|
|
1385
|
+
else:
|
|
1386
|
+
for i, h in enumerate(hosts, 1):
|
|
1387
|
+
r = await copy_host(h)
|
|
1388
|
+
results.append(r)
|
|
1389
|
+
if r["status"] == "success":
|
|
1390
|
+
files.append(cfg)
|
|
1391
|
+
locs.append(f"{rmt_cfg} on {r['hostname']}")
|
|
1392
|
+
else:
|
|
1393
|
+
errors.extend(r["errors"])
|
|
1394
|
+
if ctx:
|
|
1395
|
+
await ctx.report_progress(progress=i, total=total)
|
|
1396
|
+
logger.debug(f"Progress: {i}/{total}")
|
|
1397
|
+
|
|
1398
|
+
logger.debug(f"Done SSH config copy for {group}")
|
|
1399
|
+
msg = (
|
|
1400
|
+
f"Copied cfg to {group}"
|
|
1401
|
+
if not errors
|
|
1402
|
+
else f"Copy failed for some in {group}"
|
|
1403
|
+
)
|
|
1404
|
+
return ResponseBuilder.build(
|
|
1405
|
+
200 if not errors else 500,
|
|
1406
|
+
msg,
|
|
1407
|
+
{
|
|
1408
|
+
"inventory_path": inventory_path,
|
|
1409
|
+
"group": group,
|
|
1410
|
+
"cfg": cfg,
|
|
1411
|
+
"rmt_cfg": rmt_cfg,
|
|
1412
|
+
"host_results": results,
|
|
1413
|
+
},
|
|
1414
|
+
"; ".join(errors),
|
|
1415
|
+
files,
|
|
1416
|
+
locs,
|
|
1417
|
+
errors,
|
|
1418
|
+
)
|
|
1419
|
+
|
|
1420
|
+
except Exception as e:
|
|
1421
|
+
logger.error(f"Copy all fail: {e}")
|
|
1422
|
+
return ResponseBuilder.build(
|
|
1423
|
+
500,
|
|
1424
|
+
f"Copy all fail: {e}",
|
|
1425
|
+
{
|
|
1426
|
+
"inventory_path": inventory_path,
|
|
1427
|
+
"group": group,
|
|
1428
|
+
"cfg": cfg,
|
|
1429
|
+
"rmt_cfg": rmt_cfg,
|
|
1430
|
+
},
|
|
1431
|
+
str(e),
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
@mcp.tool(
|
|
1436
|
+
annotations={
|
|
1437
|
+
"title": "Rotate SSH Keys for All",
|
|
1438
|
+
"readOnlyHint": False,
|
|
1439
|
+
"destructiveHint": True,
|
|
1440
|
+
"idempotentHint": False,
|
|
1441
|
+
},
|
|
1442
|
+
tags={"remote_access"},
|
|
1443
|
+
)
|
|
1444
|
+
async def rotate_ssh_key_on_all(
|
|
1445
|
+
inventory_path: str = Field(
|
|
1446
|
+
description="YAML inventory path.",
|
|
1447
|
+
default=os.environ.get("TUNNEL_INVENTORY", None),
|
|
1448
|
+
),
|
|
1449
|
+
key_pfx: str = Field(
|
|
1450
|
+
description="Prefix for new keys.", default=os.path.expanduser("~/.ssh/id_")
|
|
1451
|
+
),
|
|
1452
|
+
group: str = Field(
|
|
1453
|
+
description="Target group.",
|
|
1454
|
+
default=os.environ.get("TUNNEL_INVENTORY_GROUP", "all"),
|
|
1455
|
+
),
|
|
1456
|
+
parallel: bool = Field(
|
|
1457
|
+
description="Run parallel.",
|
|
1458
|
+
default=to_boolean(os.environ.get("TUNNEL_PARALLEL", False)),
|
|
1459
|
+
),
|
|
1460
|
+
max_threads: int = Field(
|
|
1461
|
+
description="Max threads.", default=int(os.environ.get("TUNNEL_MAX_THREADS", 5))
|
|
1462
|
+
),
|
|
1463
|
+
log: Optional[str] = Field(
|
|
1464
|
+
description="Log file.", default=os.environ.get("TUNNEL_LOG_FILE", None)
|
|
1465
|
+
),
|
|
1466
|
+
ctx: Context = Field(description="MCP context.", default=None),
|
|
1467
|
+
) -> Dict:
|
|
1468
|
+
"""Rotate SSH keys for all hosts in YAML group. Expected return object type: dict"""
|
|
1469
|
+
logger = logging.getLogger("TunnelServer")
|
|
1470
|
+
if err := setup_logging(log, logger):
|
|
1471
|
+
return err
|
|
1472
|
+
logger.debug(f"Rotate SSH keys: inv={inventory_path}, group={group}")
|
|
1473
|
+
|
|
1474
|
+
if not inventory_path:
|
|
1475
|
+
logger.error("Need inventory_path")
|
|
1476
|
+
return ResponseBuilder.build(
|
|
1477
|
+
400,
|
|
1478
|
+
"Need inventory_path",
|
|
1479
|
+
{"inventory_path": inventory_path, "group": group, "key_pfx": key_pfx},
|
|
1480
|
+
errors=["Need inventory_path"],
|
|
1481
|
+
)
|
|
1482
|
+
|
|
1483
|
+
try:
|
|
1484
|
+
hosts, err = load_inventory(inventory_path, group, logger)
|
|
1485
|
+
if err:
|
|
1486
|
+
return err
|
|
1487
|
+
|
|
1488
|
+
total = len(hosts)
|
|
1489
|
+
if ctx:
|
|
1490
|
+
await ctx.report_progress(progress=0, total=total)
|
|
1491
|
+
logger.debug(f"Progress: 0/{total}")
|
|
1492
|
+
|
|
1493
|
+
results, files, locs, errors = [], [], [], []
|
|
1494
|
+
|
|
1495
|
+
async def rotate_host(h: Dict) -> Dict:
|
|
1496
|
+
key = os.path.expanduser(key_pfx + h["hostname"])
|
|
1497
|
+
try:
|
|
1498
|
+
t = Tunnel(
|
|
1499
|
+
remote_host=h["hostname"],
|
|
1500
|
+
username=h["username"],
|
|
1501
|
+
password=h.get("password"),
|
|
1502
|
+
identity_file=h.get("key_path"),
|
|
1503
|
+
)
|
|
1504
|
+
t.rotate_ssh_key(key)
|
|
1505
|
+
logger.info(f"Rotated key for {h['hostname']}: {key}")
|
|
1506
|
+
return {
|
|
1507
|
+
"hostname": h["hostname"],
|
|
1508
|
+
"status": "success",
|
|
1509
|
+
"message": f"Rotated key to {key}",
|
|
1510
|
+
"errors": [],
|
|
1511
|
+
"new_key_path": key,
|
|
1512
|
+
}
|
|
1513
|
+
except Exception as e:
|
|
1514
|
+
logger.error(f"Rotate fail {h['hostname']}: {e}")
|
|
1515
|
+
return {
|
|
1516
|
+
"hostname": h["hostname"],
|
|
1517
|
+
"status": "failed",
|
|
1518
|
+
"message": f"Rotate fail: {e}",
|
|
1519
|
+
"errors": [str(e)],
|
|
1520
|
+
"new_key_path": key,
|
|
1521
|
+
}
|
|
1522
|
+
finally:
|
|
1523
|
+
if "t" in locals():
|
|
1524
|
+
t.close()
|
|
1525
|
+
|
|
1526
|
+
if parallel:
|
|
1527
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as ex:
|
|
1528
|
+
futures = [
|
|
1529
|
+
ex.submit(lambda h: asyncio.run(rotate_host(h)), h) for h in hosts
|
|
1530
|
+
]
|
|
1531
|
+
for i, f in enumerate(concurrent.futures.as_completed(futures), 1):
|
|
1532
|
+
try:
|
|
1533
|
+
r = f.result()
|
|
1534
|
+
results.append(r)
|
|
1535
|
+
if r["status"] == "success":
|
|
1536
|
+
files.append(r["new_key_path"] + ".pub")
|
|
1537
|
+
locs.append(f"~/.ssh/authorized_keys on {r['hostname']}")
|
|
1538
|
+
else:
|
|
1539
|
+
errors.extend(r["errors"])
|
|
1540
|
+
if ctx:
|
|
1541
|
+
await ctx.report_progress(progress=i, total=total)
|
|
1542
|
+
logger.debug(f"Progress: {i}/{total}")
|
|
1543
|
+
except Exception as e:
|
|
1544
|
+
logger.error(f"Parallel err: {e}")
|
|
1545
|
+
results.append(
|
|
1546
|
+
{
|
|
1547
|
+
"hostname": "unknown",
|
|
1548
|
+
"status": "failed",
|
|
1549
|
+
"message": f"Parallel err: {e}",
|
|
1550
|
+
"errors": [str(e)],
|
|
1551
|
+
"new_key_path": None,
|
|
1552
|
+
}
|
|
1553
|
+
)
|
|
1554
|
+
errors.append(str(e))
|
|
1555
|
+
else:
|
|
1556
|
+
for i, h in enumerate(hosts, 1):
|
|
1557
|
+
r = await rotate_host(h)
|
|
1558
|
+
results.append(r)
|
|
1559
|
+
if r["status"] == "success":
|
|
1560
|
+
files.append(r["new_key_path"] + ".pub")
|
|
1561
|
+
locs.append(f"~/.ssh/authorized_keys on {r['hostname']}")
|
|
1562
|
+
else:
|
|
1563
|
+
errors.extend(r["errors"])
|
|
1564
|
+
if ctx:
|
|
1565
|
+
await ctx.report_progress(progress=i, total=total)
|
|
1566
|
+
logger.debug(f"Progress: {i}/{total}")
|
|
1567
|
+
|
|
1568
|
+
logger.debug(f"Done SSH key rotate for {group}")
|
|
1569
|
+
msg = (
|
|
1570
|
+
f"Rotated keys for {group}"
|
|
1571
|
+
if not errors
|
|
1572
|
+
else f"Rotate failed for some in {group}"
|
|
1573
|
+
)
|
|
1574
|
+
return ResponseBuilder.build(
|
|
1575
|
+
200 if not errors else 500,
|
|
1576
|
+
msg,
|
|
1577
|
+
{
|
|
1578
|
+
"inventory_path": inventory_path,
|
|
1579
|
+
"group": group,
|
|
1580
|
+
"key_pfx": key_pfx,
|
|
1581
|
+
"host_results": results,
|
|
1582
|
+
},
|
|
1583
|
+
"; ".join(errors),
|
|
1584
|
+
files,
|
|
1585
|
+
locs,
|
|
1586
|
+
errors,
|
|
1587
|
+
)
|
|
1588
|
+
|
|
1589
|
+
except Exception as e:
|
|
1590
|
+
logger.error(f"Rotate all fail: {e}")
|
|
1591
|
+
return ResponseBuilder.build(
|
|
1592
|
+
500,
|
|
1593
|
+
f"Rotate all fail: {e}",
|
|
1594
|
+
{"inventory_path": inventory_path, "group": group, "key_pfx": key_pfx},
|
|
1595
|
+
str(e),
|
|
1596
|
+
)
|
|
270
1597
|
|
|
271
1598
|
|
|
272
1599
|
def tunnel_manager_mcp():
|