tunnel-manager 0.0.2__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.
- tunnel_manager/__init__.py +16 -0
- tunnel_manager/__main__.py +6 -0
- tunnel_manager/tunnel_manager.py +190 -0
- tunnel_manager/tunnel_manager_mcp.py +316 -0
- tunnel_manager-0.0.2.dist-info/METADATA +190 -0
- tunnel_manager-0.0.2.dist-info/RECORD +10 -0
- tunnel_manager-0.0.2.dist-info/WHEEL +5 -0
- tunnel_manager-0.0.2.dist-info/entry_points.txt +2 -0
- tunnel_manager-0.0.2.dist-info/licenses/LICENSE +20 -0
- tunnel_manager-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# coding: utf-8
|
|
3
|
+
|
|
4
|
+
from tunnel_manager.tunnel_manager import (
|
|
5
|
+
Tunnel,
|
|
6
|
+
)
|
|
7
|
+
from tunnel_manager.tunnel_manager_mcp import tunnel_manager_mcp
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
tunnel-manager
|
|
11
|
+
|
|
12
|
+
Create SSH tunnels to your remote hosts!
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = ["tunnel_manager_mcp", "Tunnel"]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import paramiko
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Tunnel:
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
remote_host: str,
|
|
10
|
+
port: int = 22,
|
|
11
|
+
identity_file: str = None,
|
|
12
|
+
certificate_file: str = None,
|
|
13
|
+
proxy_command: str = None,
|
|
14
|
+
log_file: str = None,
|
|
15
|
+
):
|
|
16
|
+
"""
|
|
17
|
+
Initialize the Tunnel class.
|
|
18
|
+
|
|
19
|
+
:param remote_host: The hostname or IP of the remote host.
|
|
20
|
+
:param identity_file: Optional path to the private key file (overrides config).
|
|
21
|
+
:param certificate_file: Optional path to the certificate file (overrides config, used for Teleport).
|
|
22
|
+
:param proxy_command: Optional proxy command string (overrides config, used for Teleport proxying).
|
|
23
|
+
:param log_file: Optional path to a log file for recording operations.
|
|
24
|
+
"""
|
|
25
|
+
self.remote_host = remote_host
|
|
26
|
+
self.port = port
|
|
27
|
+
self.ssh_client = None
|
|
28
|
+
self.sftp = None
|
|
29
|
+
self.logger = None
|
|
30
|
+
|
|
31
|
+
# Load from ~/.ssh/config if not overridden
|
|
32
|
+
ssh_config_path = os.path.expanduser("~/.ssh/config")
|
|
33
|
+
self.ssh_config = paramiko.SSHConfig()
|
|
34
|
+
if os.path.exists(ssh_config_path):
|
|
35
|
+
with open(ssh_config_path) as f:
|
|
36
|
+
self.ssh_config.parse(f)
|
|
37
|
+
host_config = self.ssh_config.lookup(remote_host) or {}
|
|
38
|
+
|
|
39
|
+
self.identity_file = identity_file or (
|
|
40
|
+
host_config.get("identityfile", [None])[0]
|
|
41
|
+
if "identityfile" in host_config
|
|
42
|
+
else None
|
|
43
|
+
)
|
|
44
|
+
self.certificate_file = certificate_file or host_config.get("certificatefile")
|
|
45
|
+
self.proxy_command = proxy_command or host_config.get("proxycommand")
|
|
46
|
+
|
|
47
|
+
if not self.identity_file:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"Identity file must be provided either via parameter or in ~/.ssh/config."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if log_file:
|
|
53
|
+
logging.basicConfig(
|
|
54
|
+
filename=log_file,
|
|
55
|
+
level=logging.INFO,
|
|
56
|
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
57
|
+
)
|
|
58
|
+
self.logger = logging.getLogger(__name__)
|
|
59
|
+
self.logger.info(f"Tunnel initialized for host: {remote_host}")
|
|
60
|
+
|
|
61
|
+
def connect(self):
|
|
62
|
+
"""
|
|
63
|
+
Establish the SSH connection if not already connected.
|
|
64
|
+
"""
|
|
65
|
+
if (
|
|
66
|
+
self.ssh_client
|
|
67
|
+
and self.ssh_client.get_transport()
|
|
68
|
+
and self.ssh_client.get_transport().is_active()
|
|
69
|
+
):
|
|
70
|
+
return # Already connected
|
|
71
|
+
|
|
72
|
+
self.ssh_client = paramiko.SSHClient()
|
|
73
|
+
self.ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
74
|
+
|
|
75
|
+
proxy = None
|
|
76
|
+
if self.proxy_command:
|
|
77
|
+
proxy = paramiko.ProxyCommand(self.proxy_command)
|
|
78
|
+
if self.logger:
|
|
79
|
+
self.logger.info(f"Using proxy command: {self.proxy_command}")
|
|
80
|
+
|
|
81
|
+
private_key = paramiko.RSAKey.from_private_key_file(self.identity_file)
|
|
82
|
+
if self.certificate_file:
|
|
83
|
+
private_key.load_certificate(self.certificate_file)
|
|
84
|
+
if self.logger:
|
|
85
|
+
self.logger.info(f"Loaded certificate: {self.certificate_file}")
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
self.ssh_client.connect(
|
|
89
|
+
self.remote_host,
|
|
90
|
+
port=self.port,
|
|
91
|
+
pkey=private_key,
|
|
92
|
+
sock=proxy,
|
|
93
|
+
auth_timeout=30,
|
|
94
|
+
look_for_keys=False,
|
|
95
|
+
allow_agent=False,
|
|
96
|
+
)
|
|
97
|
+
if self.logger:
|
|
98
|
+
self.logger.info(f"Connected to {self.remote_host}")
|
|
99
|
+
except Exception as e:
|
|
100
|
+
if self.logger:
|
|
101
|
+
self.logger.error(f"Connection failed: {str(e)}")
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
def run_command(self, command):
|
|
105
|
+
"""
|
|
106
|
+
Run a shell command on the remote host.
|
|
107
|
+
|
|
108
|
+
:param command: The command to execute.
|
|
109
|
+
:return: Tuple of (stdout, stderr) as strings.
|
|
110
|
+
"""
|
|
111
|
+
self.connect()
|
|
112
|
+
try:
|
|
113
|
+
stdin, stdout, stderr = self.ssh_client.exec_command(command)
|
|
114
|
+
out = stdout.read().decode("utf-8").strip()
|
|
115
|
+
err = stderr.read().decode("utf-8").strip()
|
|
116
|
+
if self.logger:
|
|
117
|
+
self.logger.info(
|
|
118
|
+
f"Command executed: {command}\nOutput: {out}\nError: {err}"
|
|
119
|
+
)
|
|
120
|
+
return out, err
|
|
121
|
+
except Exception as e:
|
|
122
|
+
if self.logger:
|
|
123
|
+
self.logger.error(f"Command execution failed: {str(e)}")
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
def send_file(self, local_path, remote_path):
|
|
127
|
+
"""
|
|
128
|
+
Send (upload) a file to the remote host.
|
|
129
|
+
|
|
130
|
+
:param local_path: Path to the local file.
|
|
131
|
+
:param remote_path: Path on the remote host.
|
|
132
|
+
"""
|
|
133
|
+
self.connect()
|
|
134
|
+
try:
|
|
135
|
+
if not self.sftp:
|
|
136
|
+
self.sftp = self.ssh_client.open_sftp()
|
|
137
|
+
self.sftp.put(local_path, remote_path)
|
|
138
|
+
if self.logger:
|
|
139
|
+
self.logger.info(f"File sent: {local_path} -> {remote_path}")
|
|
140
|
+
except Exception as e:
|
|
141
|
+
if self.logger:
|
|
142
|
+
self.logger.error(f"File send failed: {str(e)}")
|
|
143
|
+
raise
|
|
144
|
+
finally:
|
|
145
|
+
if self.sftp:
|
|
146
|
+
self.sftp.close()
|
|
147
|
+
self.sftp = None
|
|
148
|
+
|
|
149
|
+
def receive_file(self, remote_path, local_path):
|
|
150
|
+
"""
|
|
151
|
+
Receive (download) a file from the remote host.
|
|
152
|
+
|
|
153
|
+
:param remote_path: Path on the remote host.
|
|
154
|
+
:param local_path: Path to save the local file.
|
|
155
|
+
"""
|
|
156
|
+
self.connect()
|
|
157
|
+
try:
|
|
158
|
+
if not self.sftp:
|
|
159
|
+
self.sftp = self.ssh_client.open_sftp()
|
|
160
|
+
self.sftp.get(remote_path, local_path)
|
|
161
|
+
if self.logger:
|
|
162
|
+
self.logger.info(f"File received: {remote_path} -> {local_path}")
|
|
163
|
+
except Exception as e:
|
|
164
|
+
if self.logger:
|
|
165
|
+
self.logger.error(f"File receive failed: {str(e)}")
|
|
166
|
+
raise
|
|
167
|
+
finally:
|
|
168
|
+
if self.sftp:
|
|
169
|
+
self.sftp.close()
|
|
170
|
+
self.sftp = None
|
|
171
|
+
|
|
172
|
+
def close(self):
|
|
173
|
+
"""
|
|
174
|
+
Close the SSH connection.
|
|
175
|
+
"""
|
|
176
|
+
if self.ssh_client:
|
|
177
|
+
self.ssh_client.close()
|
|
178
|
+
if self.logger:
|
|
179
|
+
self.logger.info(f"Connection closed for {self.remote_host}")
|
|
180
|
+
self.ssh_client = None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# Example usage (commented out):
|
|
184
|
+
# tunnel = Tunnel("your-remote-host.example.com", log_file="tunnel.log")
|
|
185
|
+
# tunnel.connect()
|
|
186
|
+
# out, err = tunnel.run_command("ls -la")
|
|
187
|
+
# print(out)
|
|
188
|
+
# tunnel.send_file("/local/file.txt", "/remote/file.txt")
|
|
189
|
+
# tunnel.receive_file("/remote/file.txt", "/local/downloaded.txt")
|
|
190
|
+
# tunnel.close()
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
# coding: utf-8
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from tunnel_manager.tunnel_manager import Tunnel
|
|
9
|
+
from fastmcp import FastMCP, Context
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
|
|
12
|
+
logging.basicConfig(
|
|
13
|
+
filename="tunnel_mcp.log",
|
|
14
|
+
level=logging.INFO,
|
|
15
|
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
mcp = FastMCP(name="TunnelServer")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@mcp.tool(
|
|
22
|
+
annotations={
|
|
23
|
+
"title": "Run Remote Command",
|
|
24
|
+
"readOnlyHint": True,
|
|
25
|
+
"destructiveHint": True,
|
|
26
|
+
"idempotentHint": False,
|
|
27
|
+
"openWorldHint": False,
|
|
28
|
+
},
|
|
29
|
+
tags={"remote_access"},
|
|
30
|
+
)
|
|
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),
|
|
35
|
+
),
|
|
36
|
+
remote_port: str = Field(
|
|
37
|
+
description="The remote host's port to connect to.",
|
|
38
|
+
default=os.environ.get("TUNNEL_REMOTE_PORT", None),
|
|
39
|
+
),
|
|
40
|
+
command: str = Field(
|
|
41
|
+
description="The shell command to run on the remote host.", default=None
|
|
42
|
+
),
|
|
43
|
+
identity_file: Optional[str] = Field(
|
|
44
|
+
description="Path to the private key file.",
|
|
45
|
+
default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
|
|
46
|
+
),
|
|
47
|
+
certificate_file: Optional[str] = Field(
|
|
48
|
+
description="Path to the certificate file (for Teleport).",
|
|
49
|
+
default=os.environ.get("TUNNEL_CERTIFICATE", None),
|
|
50
|
+
),
|
|
51
|
+
proxy_command: Optional[str] = Field(
|
|
52
|
+
description="Proxy command (for Teleport).",
|
|
53
|
+
default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
|
|
54
|
+
),
|
|
55
|
+
log_file: Optional[str] = Field(
|
|
56
|
+
description="Path to log file for this operation.",
|
|
57
|
+
default=os.environ.get("TUNNEL_LOG_FILE", None),
|
|
58
|
+
),
|
|
59
|
+
ctx: Context = Field(
|
|
60
|
+
description="MCP context for progress reporting.", default=None
|
|
61
|
+
),
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Runs a shell command on a remote host via SSH or Teleport."""
|
|
64
|
+
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
|
+
|
|
72
|
+
try:
|
|
73
|
+
tunnel = Tunnel(
|
|
74
|
+
remote_host, identity_file, certificate_file, proxy_command, log_file
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if ctx:
|
|
78
|
+
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
|
+
|
|
84
|
+
if ctx:
|
|
85
|
+
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}"
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logger.error(f"Failed to run command: {str(e)}")
|
|
92
|
+
raise RuntimeError(f"Failed to run command: {str(e)}")
|
|
93
|
+
finally:
|
|
94
|
+
if "tunnel" in locals():
|
|
95
|
+
tunnel.close()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@mcp.tool(
|
|
99
|
+
annotations={
|
|
100
|
+
"title": "Upload File",
|
|
101
|
+
"readOnlyHint": False,
|
|
102
|
+
"destructiveHint": True,
|
|
103
|
+
"idempotentHint": False,
|
|
104
|
+
"openWorldHint": False,
|
|
105
|
+
},
|
|
106
|
+
tags={"remote_access"},
|
|
107
|
+
)
|
|
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.",
|
|
121
|
+
default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
|
|
122
|
+
),
|
|
123
|
+
certificate_file: Optional[str] = Field(
|
|
124
|
+
description="Path to the certificate file (for Teleport).",
|
|
125
|
+
default=os.environ.get("TUNNEL_CERTIFICATE", None),
|
|
126
|
+
),
|
|
127
|
+
proxy_command: Optional[str] = Field(
|
|
128
|
+
description="Proxy command (for Teleport).",
|
|
129
|
+
default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
|
|
130
|
+
),
|
|
131
|
+
log_file: Optional[str] = Field(
|
|
132
|
+
description="Path to log file for this operation.",
|
|
133
|
+
default=os.environ.get("TUNNEL_LOG_FILE", None),
|
|
134
|
+
),
|
|
135
|
+
ctx: Context = Field(
|
|
136
|
+
description="MCP context for progress reporting.", default=None
|
|
137
|
+
),
|
|
138
|
+
) -> str:
|
|
139
|
+
"""Uploads a file to a remote host via SSH or Teleport."""
|
|
140
|
+
logger = logging.getLogger("TunnelServer")
|
|
141
|
+
logger.debug(
|
|
142
|
+
f"Starting upload_file for host: {remote_host}, local: {local_path}, remote: {remote_path}"
|
|
143
|
+
)
|
|
144
|
+
|
|
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.")
|
|
147
|
+
|
|
148
|
+
if not os.path.exists(local_path):
|
|
149
|
+
raise ValueError(f"Local file does not exist: {local_path}")
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
tunnel = Tunnel(
|
|
153
|
+
remote_host, identity_file, certificate_file, proxy_command, log_file
|
|
154
|
+
)
|
|
155
|
+
tunnel.connect()
|
|
156
|
+
|
|
157
|
+
if ctx:
|
|
158
|
+
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)
|
|
163
|
+
transferred = 0
|
|
164
|
+
|
|
165
|
+
def progress_callback(transf, total):
|
|
166
|
+
nonlocal transferred
|
|
167
|
+
transferred = transf
|
|
168
|
+
|
|
169
|
+
sftp.put(local_path, remote_path, callback=progress_callback)
|
|
170
|
+
|
|
171
|
+
if ctx:
|
|
172
|
+
await ctx.report_progress(progress=100, total=100)
|
|
173
|
+
logger.debug("Reported final progress: 100/100")
|
|
174
|
+
|
|
175
|
+
sftp.close()
|
|
176
|
+
logger.debug(f"File uploaded: {local_path} -> {remote_path}")
|
|
177
|
+
return f"File uploaded successfully to {remote_path}"
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(f"Failed to upload file: {str(e)}")
|
|
180
|
+
raise RuntimeError(f"Failed to upload file: {str(e)}")
|
|
181
|
+
finally:
|
|
182
|
+
if "tunnel" in locals():
|
|
183
|
+
tunnel.close()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@mcp.tool(
|
|
187
|
+
annotations={
|
|
188
|
+
"title": "Download File",
|
|
189
|
+
"readOnlyHint": False,
|
|
190
|
+
"destructiveHint": False,
|
|
191
|
+
"idempotentHint": True,
|
|
192
|
+
"openWorldHint": False,
|
|
193
|
+
},
|
|
194
|
+
tags={"remote_access"},
|
|
195
|
+
)
|
|
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.",
|
|
209
|
+
default=os.environ.get("TUNNEL_IDENTITY_FILE", None),
|
|
210
|
+
),
|
|
211
|
+
certificate_file: Optional[str] = Field(
|
|
212
|
+
description="Path to the certificate file (for Teleport).",
|
|
213
|
+
default=os.environ.get("TUNNEL_CERTIFICATE", None),
|
|
214
|
+
),
|
|
215
|
+
proxy_command: Optional[str] = Field(
|
|
216
|
+
description="Proxy command (for Teleport).",
|
|
217
|
+
default=os.environ.get("TUNNEL_PROXY_COMMAND", None),
|
|
218
|
+
),
|
|
219
|
+
log_file: Optional[str] = Field(
|
|
220
|
+
description="Path to log file for this operation.",
|
|
221
|
+
default=os.environ.get("TUNNEL_LOG_FILE", None),
|
|
222
|
+
),
|
|
223
|
+
ctx: Context = Field(
|
|
224
|
+
description="MCP context for progress reporting.", default=None
|
|
225
|
+
),
|
|
226
|
+
) -> str:
|
|
227
|
+
"""Downloads a file from a remote host via SSH or Teleport."""
|
|
228
|
+
logger = logging.getLogger("TunnelServer")
|
|
229
|
+
logger.debug(
|
|
230
|
+
f"Starting download_file for host: {remote_host}, remote: {remote_path}, local: {local_path}"
|
|
231
|
+
)
|
|
232
|
+
|
|
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
|
+
|
|
236
|
+
try:
|
|
237
|
+
tunnel = Tunnel(
|
|
238
|
+
remote_host, identity_file, certificate_file, proxy_command, log_file
|
|
239
|
+
)
|
|
240
|
+
tunnel.connect()
|
|
241
|
+
|
|
242
|
+
if ctx:
|
|
243
|
+
await ctx.report_progress(progress=0, total=100)
|
|
244
|
+
logger.debug("Reported initial progress: 0/100")
|
|
245
|
+
|
|
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
|
+
|
|
251
|
+
def progress_callback(transf, total):
|
|
252
|
+
nonlocal transferred
|
|
253
|
+
transferred = transf
|
|
254
|
+
|
|
255
|
+
sftp.get(remote_path, local_path, callback=progress_callback)
|
|
256
|
+
|
|
257
|
+
if ctx:
|
|
258
|
+
await ctx.report_progress(progress=100, total=100)
|
|
259
|
+
logger.debug("Reported final progress: 100/100")
|
|
260
|
+
|
|
261
|
+
sftp.close()
|
|
262
|
+
logger.debug(f"File downloaded: {remote_path} -> {local_path}")
|
|
263
|
+
return f"File downloaded successfully to {local_path}"
|
|
264
|
+
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()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def tunnel_mcp():
|
|
273
|
+
parser = argparse.ArgumentParser(
|
|
274
|
+
description="Tunnel MCP Server for remote SSH and file operations",
|
|
275
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
276
|
+
)
|
|
277
|
+
parser.add_argument(
|
|
278
|
+
"-t",
|
|
279
|
+
"--transport",
|
|
280
|
+
choices=["stdio", "http"],
|
|
281
|
+
default="stdio",
|
|
282
|
+
help="Transport method for the MCP server (stdio or http).",
|
|
283
|
+
)
|
|
284
|
+
parser.add_argument(
|
|
285
|
+
"--host", default="0.0.0.0", help="Host address for HTTP transport."
|
|
286
|
+
)
|
|
287
|
+
parser.add_argument(
|
|
288
|
+
"-p",
|
|
289
|
+
"--port",
|
|
290
|
+
type=int,
|
|
291
|
+
default=8000,
|
|
292
|
+
help="Port for HTTP transport (0-65535).",
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
args = parser.parse_args()
|
|
296
|
+
|
|
297
|
+
if args.port < 0 or args.port > 65535:
|
|
298
|
+
print(f"Error: Port {args.port} is out of valid range (0-65535).")
|
|
299
|
+
sys.exit(1)
|
|
300
|
+
|
|
301
|
+
logger = logging.getLogger("TunnelServer")
|
|
302
|
+
if args.transport == "stdio":
|
|
303
|
+
logger.info("Starting MCP server with stdio transport")
|
|
304
|
+
mcp.run(transport="stdio")
|
|
305
|
+
elif args.transport == "http":
|
|
306
|
+
logger.info(
|
|
307
|
+
f"Starting MCP server with HTTP transport on {args.host}:{args.port}"
|
|
308
|
+
)
|
|
309
|
+
mcp.run(transport="http", host=args.host, port=args.port)
|
|
310
|
+
else:
|
|
311
|
+
logger.error("Transport not supported")
|
|
312
|
+
sys.exit(1)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
if __name__ == "__main__":
|
|
316
|
+
tunnel_mcp()
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tunnel-manager
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Create SSH Tunnels to your remote hosts and host as an MCP Server for Agentic AI!
|
|
5
|
+
Author-email: Audel Rouhi <knucklessg1@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
8
|
+
Classifier: License :: Public Domain
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: fastmcp>=2.11.3
|
|
16
|
+
Requires-Dist: paramiko>=4.0.0
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Tunnel Manager
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+

|
|
23
|
+

|
|
24
|
+

|
|
25
|
+

|
|
26
|
+

|
|
27
|
+

|
|
28
|
+
|
|
29
|
+

|
|
30
|
+

|
|
31
|
+

|
|
32
|
+

|
|
33
|
+
|
|
34
|
+

|
|
35
|
+

|
|
36
|
+

|
|
37
|
+

|
|
38
|
+

|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
*Version: 0.0.2*
|
|
42
|
+
|
|
43
|
+
This project provides a Python-based `Tunnel` class for secure SSH connections and file transfers,
|
|
44
|
+
integrated with a FastMCP server (`tunnel_mcp.py`) to expose these capabilities as tools for AI-driven workflows.
|
|
45
|
+
The implementation supports both standard SSH (e.g., for local networks) and
|
|
46
|
+
Teleport's secure access platform, leveraging the `paramiko` library for SSH operations.
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
### Tunnel Class
|
|
51
|
+
- **Purpose**: Facilitates secure SSH connections and file transfers to remote hosts.
|
|
52
|
+
- **Key Functionality**:
|
|
53
|
+
- **Run Remote Commands**: Execute shell commands on a remote host and retrieve output.
|
|
54
|
+
- **File Upload/Download**: Transfer files to/from a remote host using SFTP.
|
|
55
|
+
- **Teleport Support**: Seamlessly integrates with Teleport's certificate-based authentication and proxying.
|
|
56
|
+
- **Configuration Flexibility**: Loads SSH settings from `~/.ssh/config` by default, with optional overrides for identity files, certificates, and proxy commands.
|
|
57
|
+
- **Logging**: Optional file-based logging for debugging and auditing.
|
|
58
|
+
|
|
59
|
+
### FastMCP Server
|
|
60
|
+
- **Purpose**: Exposes `Tunnel` class functionality as a FastMCP server, enabling AI tools to perform remote operations programmatically.
|
|
61
|
+
- **Tools Provided**:
|
|
62
|
+
- `run_remote_command`: Runs a shell command on a remote host and returns output.
|
|
63
|
+
- `upload_file`: Uploads a file to a remote host via SFTP.
|
|
64
|
+
- `download_file`: Downloads a file from a remote host via SFTP.
|
|
65
|
+
- **Transport Options**: Supports `stdio` (for local scripting) and `http` (for networked access) transport modes.
|
|
66
|
+
- **Progress Reporting**: Integrates with FastMCP's `Context` for progress updates during operations.
|
|
67
|
+
- **Logging**: Comprehensive logging to a file (`tunnel_mcp.log` by default).
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
<details>
|
|
71
|
+
<summary><b>Usage:</b></summary>
|
|
72
|
+
|
|
73
|
+
## Tunnel Class
|
|
74
|
+
The `Tunnel` class can be used standalone for SSH operations. Example:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from tunnel_manager import Tunnel
|
|
78
|
+
|
|
79
|
+
# Initialize with a remote host (assumes ~/.ssh/config or explicit params)
|
|
80
|
+
tunnel = Tunnel(
|
|
81
|
+
remote_host="example.com",
|
|
82
|
+
identity_file="/path/to/id_rsa",
|
|
83
|
+
certificate_file="/path/to/cert", # Optional for Teleport
|
|
84
|
+
proxy_command="tsh proxy ssh %h", # Optional for Teleport
|
|
85
|
+
log_file="tunnel.log"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Connect and run a command
|
|
89
|
+
tunnel.connect()
|
|
90
|
+
out, err = tunnel.run_command("ls -la /tmp")
|
|
91
|
+
print(f"Output: {out}\nError: {err}")
|
|
92
|
+
|
|
93
|
+
# Upload a file
|
|
94
|
+
tunnel.send_file("/local/file.txt", "/remote/file.txt")
|
|
95
|
+
|
|
96
|
+
# Download a file
|
|
97
|
+
tunnel.receive_file("/remote/file.txt", "/local/downloaded.txt")
|
|
98
|
+
|
|
99
|
+
# Close the connection
|
|
100
|
+
tunnel.close()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
## FastMCP Server
|
|
105
|
+
The FastMCP server exposes the `Tunnel` functionality as AI-accessible tools. Start the server with:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
python tunnel_mcp.py --transport stdio
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Or for HTTP transport:
|
|
112
|
+
```bash
|
|
113
|
+
python tunnel_mcp.py --transport http --host 127.0.0.1 --port 8080
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
</details>
|
|
117
|
+
|
|
118
|
+
<details>
|
|
119
|
+
<summary><b>Installation Instructions:</b></summary>
|
|
120
|
+
|
|
121
|
+
## Use with AI
|
|
122
|
+
|
|
123
|
+
Configure `mcp.json`
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"mcpServers": {
|
|
127
|
+
"tunnel_manager": {
|
|
128
|
+
"command": "uv",
|
|
129
|
+
"args": [
|
|
130
|
+
"run",
|
|
131
|
+
"--with",
|
|
132
|
+
"tunnel-manager",
|
|
133
|
+
"tunnel-manager-mcp"
|
|
134
|
+
],
|
|
135
|
+
"env": {
|
|
136
|
+
"TUNNEL_REMOTE_HOST": "user@192.168.1.12", // Optional
|
|
137
|
+
"TUNNEL_REMOTE_PORT": "22", // Optional
|
|
138
|
+
"TUNNEL_IDENTITY_FILE": "", // Optional
|
|
139
|
+
"TUNNEL_CERTIFICATE": "", // Optional
|
|
140
|
+
"TUNNEL_PROXY_COMMAND": "", // Optional
|
|
141
|
+
"TUNNEL_LOG_FILE": "~./tunnel_log.txt" // Optional
|
|
142
|
+
},
|
|
143
|
+
"timeout": 200000
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Deploy MCP Server as a container
|
|
150
|
+
```bash
|
|
151
|
+
docker pull knucklessg1/tunnel-manager:latest
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Modify the `compose.yml`
|
|
155
|
+
|
|
156
|
+
```compose
|
|
157
|
+
services:
|
|
158
|
+
tunnel-manager:
|
|
159
|
+
image: knucklessg1/tunnel-manager:latest
|
|
160
|
+
environment:
|
|
161
|
+
- HOST=0.0.0.0
|
|
162
|
+
- PORT=8021
|
|
163
|
+
ports:
|
|
164
|
+
- 8021:8021
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Install Python Package
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
python -m pip install tunnel-manager
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
or
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
uv pip install --upgrade tunnel-manager
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
</details>
|
|
181
|
+
|
|
182
|
+
<details>
|
|
183
|
+
<summary><b>Repository Owners:</b></summary>
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
<img width="100%" height="180em" src="https://github-readme-stats.vercel.app/api?username=Knucklessg1&show_icons=true&hide_border=true&&count_private=true&include_all_commits=true" />
|
|
187
|
+
|
|
188
|
+

|
|
189
|
+

|
|
190
|
+
</details>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
tunnel_manager/__init__.py,sha256=9I_qajRiMP4bxiKdEIx0bKIeTg8b4x1TsYnTMfbWIJA,273
|
|
2
|
+
tunnel_manager/__main__.py,sha256=Z1uxNLjwIjJpvu97bXrvsawnghJScA52E2wtAgg5MLo,152
|
|
3
|
+
tunnel_manager/tunnel_manager.py,sha256=_MokYwLLe0ekQA1CeAaH_4JTRx0TctGUJLwai3MIQvc,6565
|
|
4
|
+
tunnel_manager/tunnel_manager_mcp.py,sha256=LWMRPCyXDo-UlrdvH9whXC542YwkKXUeqSAPXWeyrmk,10541
|
|
5
|
+
tunnel_manager-0.0.2.dist-info/licenses/LICENSE,sha256=Z1xmcrPHBnGCETO_LLQJUeaSNBSnuptcDVTt4kaPUOE,1060
|
|
6
|
+
tunnel_manager-0.0.2.dist-info/METADATA,sha256=9iZ-KRwPqLNDZ37RWmXj-4zYqVZdoYb4IW1e6LoKVa4,6568
|
|
7
|
+
tunnel_manager-0.0.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
tunnel_manager-0.0.2.dist-info/entry_points.txt,sha256=mxjinmmRNDVn_VbKHi63vkSvj7pp8btUKFQeXLm4-WY,92
|
|
9
|
+
tunnel_manager-0.0.2.dist-info/top_level.txt,sha256=AlbS1pBPQr8wIlDsmwNxGDLK4L4s4CY_X9AellT3-x8,15
|
|
10
|
+
tunnel_manager-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 2012-2023 Audel Rouhi
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tunnel_manager
|