mcp-win-stdio-ssh 0.3.3__tar.gz → 0.4.0__tar.gz
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.
- {mcp_win_stdio_ssh-0.3.3 → mcp_win_stdio_ssh-0.4.0}/.gitignore +63 -63
- {mcp_win_stdio_ssh-0.3.3 → mcp_win_stdio_ssh-0.4.0}/PKG-INFO +2 -2
- {mcp_win_stdio_ssh-0.3.3 → mcp_win_stdio_ssh-0.4.0}/README.md +70 -70
- {mcp_win_stdio_ssh-0.3.3 → mcp_win_stdio_ssh-0.4.0}/pyproject.toml +2 -2
- {mcp_win_stdio_ssh-0.3.3 → mcp_win_stdio_ssh-0.4.0}/src/mcp_win_stdio/ssh/__init__.py +1 -1
- {mcp_win_stdio_ssh-0.3.3 → mcp_win_stdio_ssh-0.4.0}/src/mcp_win_stdio/ssh/connection.py +365 -365
- {mcp_win_stdio_ssh-0.3.3 → mcp_win_stdio_ssh-0.4.0}/src/mcp_win_stdio/ssh/__main__.py +0 -0
- {mcp_win_stdio_ssh-0.3.3 → mcp_win_stdio_ssh-0.4.0}/src/mcp_win_stdio/ssh/cli.py +0 -0
- {mcp_win_stdio_ssh-0.3.3 → mcp_win_stdio_ssh-0.4.0}/src/mcp_win_stdio/ssh/guide.py +0 -0
- {mcp_win_stdio_ssh-0.3.3 → mcp_win_stdio_ssh-0.4.0}/src/mcp_win_stdio/ssh/pty_session.py +0 -0
- {mcp_win_stdio_ssh-0.3.3 → mcp_win_stdio_ssh-0.4.0}/src/mcp_win_stdio/ssh/server.py +0 -0
- {mcp_win_stdio_ssh-0.3.3 → mcp_win_stdio_ssh-0.4.0}/src/mcp_win_stdio/ssh/sftp_ops.py +0 -0
- {mcp_win_stdio_ssh-0.3.3 → mcp_win_stdio_ssh-0.4.0}/src/mcp_win_stdio/ssh/tunnels.py +0 -0
|
@@ -1,63 +1,63 @@
|
|
|
1
|
-
__pycache__/
|
|
2
|
-
*.py[cod]
|
|
3
|
-
*$py.class
|
|
4
|
-
*.so
|
|
5
|
-
.Python
|
|
6
|
-
build/
|
|
7
|
-
develop-eggs/
|
|
8
|
-
dist/
|
|
9
|
-
downloads/
|
|
10
|
-
eggs/
|
|
11
|
-
.eggs/
|
|
12
|
-
lib/
|
|
13
|
-
lib64/
|
|
14
|
-
parts/
|
|
15
|
-
sdist/
|
|
16
|
-
var/
|
|
17
|
-
wheels/
|
|
18
|
-
share/python-wheels/
|
|
19
|
-
*.egg-info/
|
|
20
|
-
.installed.cfg
|
|
21
|
-
*.egg
|
|
22
|
-
MANIFEST
|
|
23
|
-
|
|
24
|
-
*.manifest
|
|
25
|
-
*.spec
|
|
26
|
-
api.txt
|
|
27
|
-
pypi-api.txt
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
pip-log.txt
|
|
31
|
-
pip-delete-this-directory.txt
|
|
32
|
-
pypi api.txt
|
|
33
|
-
htmlcov/
|
|
34
|
-
.tox/
|
|
35
|
-
.nox/
|
|
36
|
-
.coverage
|
|
37
|
-
.coverage.*
|
|
38
|
-
.cache
|
|
39
|
-
nosetests.xml
|
|
40
|
-
coverage.xml
|
|
41
|
-
*.cover
|
|
42
|
-
*.py,cover
|
|
43
|
-
.hypothesis/
|
|
44
|
-
.pytest_cache/
|
|
45
|
-
cover/
|
|
46
|
-
|
|
47
|
-
*.mo
|
|
48
|
-
*.pot
|
|
49
|
-
|
|
50
|
-
.env
|
|
51
|
-
.venv
|
|
52
|
-
env/
|
|
53
|
-
venv/
|
|
54
|
-
ENV/
|
|
55
|
-
env.bak/
|
|
56
|
-
venv.bak/
|
|
57
|
-
|
|
58
|
-
.idea/
|
|
59
|
-
.vscode/
|
|
60
|
-
*.swp
|
|
61
|
-
*.swo
|
|
62
|
-
|
|
63
|
-
*.log
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
*.so
|
|
5
|
+
.Python
|
|
6
|
+
build/
|
|
7
|
+
develop-eggs/
|
|
8
|
+
dist/
|
|
9
|
+
downloads/
|
|
10
|
+
eggs/
|
|
11
|
+
.eggs/
|
|
12
|
+
lib/
|
|
13
|
+
lib64/
|
|
14
|
+
parts/
|
|
15
|
+
sdist/
|
|
16
|
+
var/
|
|
17
|
+
wheels/
|
|
18
|
+
share/python-wheels/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
MANIFEST
|
|
23
|
+
|
|
24
|
+
*.manifest
|
|
25
|
+
*.spec
|
|
26
|
+
api.txt
|
|
27
|
+
pypi-api.txt
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
pip-log.txt
|
|
31
|
+
pip-delete-this-directory.txt
|
|
32
|
+
pypi api.txt
|
|
33
|
+
htmlcov/
|
|
34
|
+
.tox/
|
|
35
|
+
.nox/
|
|
36
|
+
.coverage
|
|
37
|
+
.coverage.*
|
|
38
|
+
.cache
|
|
39
|
+
nosetests.xml
|
|
40
|
+
coverage.xml
|
|
41
|
+
*.cover
|
|
42
|
+
*.py,cover
|
|
43
|
+
.hypothesis/
|
|
44
|
+
.pytest_cache/
|
|
45
|
+
cover/
|
|
46
|
+
|
|
47
|
+
*.mo
|
|
48
|
+
*.pot
|
|
49
|
+
|
|
50
|
+
.env
|
|
51
|
+
.venv
|
|
52
|
+
env/
|
|
53
|
+
venv/
|
|
54
|
+
ENV/
|
|
55
|
+
env.bak/
|
|
56
|
+
venv.bak/
|
|
57
|
+
|
|
58
|
+
.idea/
|
|
59
|
+
.vscode/
|
|
60
|
+
*.swp
|
|
61
|
+
*.swo
|
|
62
|
+
|
|
63
|
+
*.log
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mcp-win-stdio-ssh
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Advanced Multi-SSH Connection & Remote Management Model Context Protocol (MCP) server for Windows & Claude: 25+ tools for multi-host pooling, ~/.ssh/config auto-discovery, PTY interactive shells, SFTP file management, systemd/docker services, background jobs, and local port forwarding tunnels.
|
|
5
5
|
Author: Mohan Kumar Indala
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
19
19
|
Classifier: Topic :: System :: Systems Administration
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Requires-Dist: cryptography>=3.3.0
|
|
22
|
-
Requires-Dist: mcp-win-stdio>=0.
|
|
22
|
+
Requires-Dist: mcp-win-stdio>=0.4.0
|
|
23
23
|
Requires-Dist: mcp>=1.2.0
|
|
24
24
|
Requires-Dist: paramiko>=3.0.0
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
@@ -1,70 +1,70 @@
|
|
|
1
|
-
# mcp-win-stdio-ssh
|
|
2
|
-
|
|
3
|
-
> Advanced Multi-SSH Connection & Remote Management Model Context Protocol (MCP) Server for Windows & Claude Desktop / Claude Code CLI: **34 Tools** for stateful SSH connection pooling, background execution, interactive PTYs, SFTP, and port tunnels. Zero mandatory environment variables (persists dynamically to `~/.mcp-win-stdio/ssh_hosts.json`).
|
|
4
|
-
|
|
5
|
-
Part of the **[mcp-win-stdio](https://github.com/indala/mcp-win-stdio)** suite.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## 🌟 Key Features
|
|
10
|
-
|
|
11
|
-
- **Multi-Host Connection Pooling & Persistence**: Connect to unlimited remote servers. Automatically parses `~/.ssh/config` (aliases, identity files, JumpHosts) and persists active host preferences.
|
|
12
|
-
- **Sticky Active Host**: Run commands against an active default host or specify `host="server_name"` per tool call.
|
|
13
|
-
- **Remote Execution & Elevated Commands**: Non-interactive command execution (`ssh_exec`), `sudo` password automation (`ssh_exec_sudo`), multi-line script execution (`ssh_exec_script`), and detached background jobs (`ssh_exec_background`).
|
|
14
|
-
- **Interactive PTY Shell Sessions**: Start stateful pseudo-terminals (`ssh_pty_start`, `ssh_pty_send`, `ssh_pty_read`) for interactive REPLs, prompts, and long-running interactive tools.
|
|
15
|
-
- **Remote Diagnostics & Services**: System health stats (`ssh_system_overview`), process monitoring (`ssh_list_processes`), systemd / docker / pm2 services (`ssh_list_services`, `ssh_service_action`), and live log tailing (`ssh_tail_logs`).
|
|
16
|
-
- **High-Performance SFTP**: Explore remote file systems (`sftp_list_dir`), read/write files (`sftp_read_file`, `sftp_write_file`), inspect metadata (`sftp_stat`), and sync local/remote folders (`sftp_upload`, `sftp_download`).
|
|
17
|
-
- **Port Forwarding & Tunnels**: Create background local-to-remote SSH port forwarding tunnels (`ssh_tunnel_open`, `ssh_tunnel_list`, `ssh_tunnel_close`) to securely access remote databases or web services.
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## 📦 Installation
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
# Standalone package installation:
|
|
25
|
-
pip install mcp-win-stdio-ssh
|
|
26
|
-
|
|
27
|
-
# Or with full mcp-win-stdio suite:
|
|
28
|
-
pip install "mcp-win-stdio[all]"
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
---
|
|
32
|
-
|
|
33
|
-
## 🚀 Quick Start & Claude Configuration
|
|
34
|
-
|
|
35
|
-
### Claude Desktop (`%APPDATA%\Claude\claude_desktop_config.json`)
|
|
36
|
-
|
|
37
|
-
```json
|
|
38
|
-
{
|
|
39
|
-
"mcpServers": {
|
|
40
|
-
"ssh": {
|
|
41
|
-
"command": "mws-ssh",
|
|
42
|
-
"args": ["run"]
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
### Claude Code CLI
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
claude mcp add ssh mws-ssh run
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
---
|
|
55
|
-
|
|
56
|
-
## 🛠️ CLI Utilities
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
# Open interactive diagnostic CLI & host manager
|
|
60
|
-
mws-ssh
|
|
61
|
-
|
|
62
|
-
# List configured SSH hosts and test latency
|
|
63
|
-
mws-ssh test
|
|
64
|
-
|
|
65
|
-
# Launch MCP stdio server
|
|
66
|
-
mws-ssh run
|
|
67
|
-
|
|
68
|
-
# View comprehensive prompt recipes & tool documentation
|
|
69
|
-
mws-ssh guide
|
|
70
|
-
```
|
|
1
|
+
# mcp-win-stdio-ssh
|
|
2
|
+
|
|
3
|
+
> Advanced Multi-SSH Connection & Remote Management Model Context Protocol (MCP) Server for Windows & Claude Desktop / Claude Code CLI: **34 Tools** for stateful SSH connection pooling, background execution, interactive PTYs, SFTP, and port tunnels. Zero mandatory environment variables (persists dynamically to `~/.mcp-win-stdio/ssh_hosts.json`).
|
|
4
|
+
|
|
5
|
+
Part of the **[mcp-win-stdio](https://github.com/indala/mcp-win-stdio)** suite.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 🌟 Key Features
|
|
10
|
+
|
|
11
|
+
- **Multi-Host Connection Pooling & Persistence**: Connect to unlimited remote servers. Automatically parses `~/.ssh/config` (aliases, identity files, JumpHosts) and persists active host preferences.
|
|
12
|
+
- **Sticky Active Host**: Run commands against an active default host or specify `host="server_name"` per tool call.
|
|
13
|
+
- **Remote Execution & Elevated Commands**: Non-interactive command execution (`ssh_exec`), `sudo` password automation (`ssh_exec_sudo`), multi-line script execution (`ssh_exec_script`), and detached background jobs (`ssh_exec_background`).
|
|
14
|
+
- **Interactive PTY Shell Sessions**: Start stateful pseudo-terminals (`ssh_pty_start`, `ssh_pty_send`, `ssh_pty_read`) for interactive REPLs, prompts, and long-running interactive tools.
|
|
15
|
+
- **Remote Diagnostics & Services**: System health stats (`ssh_system_overview`), process monitoring (`ssh_list_processes`), systemd / docker / pm2 services (`ssh_list_services`, `ssh_service_action`), and live log tailing (`ssh_tail_logs`).
|
|
16
|
+
- **High-Performance SFTP**: Explore remote file systems (`sftp_list_dir`), read/write files (`sftp_read_file`, `sftp_write_file`), inspect metadata (`sftp_stat`), and sync local/remote folders (`sftp_upload`, `sftp_download`).
|
|
17
|
+
- **Port Forwarding & Tunnels**: Create background local-to-remote SSH port forwarding tunnels (`ssh_tunnel_open`, `ssh_tunnel_list`, `ssh_tunnel_close`) to securely access remote databases or web services.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 📦 Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Standalone package installation:
|
|
25
|
+
pip install mcp-win-stdio-ssh
|
|
26
|
+
|
|
27
|
+
# Or with full mcp-win-stdio suite:
|
|
28
|
+
pip install "mcp-win-stdio[all]"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 🚀 Quick Start & Claude Configuration
|
|
34
|
+
|
|
35
|
+
### Claude Desktop (`%APPDATA%\Claude\claude_desktop_config.json`)
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"mcpServers": {
|
|
40
|
+
"ssh": {
|
|
41
|
+
"command": "mws-ssh",
|
|
42
|
+
"args": ["run"]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Claude Code CLI
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
claude mcp add ssh mws-ssh run
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 🛠️ CLI Utilities
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Open interactive diagnostic CLI & host manager
|
|
60
|
+
mws-ssh
|
|
61
|
+
|
|
62
|
+
# List configured SSH hosts and test latency
|
|
63
|
+
mws-ssh test
|
|
64
|
+
|
|
65
|
+
# Launch MCP stdio server
|
|
66
|
+
mws-ssh run
|
|
67
|
+
|
|
68
|
+
# View comprehensive prompt recipes & tool documentation
|
|
69
|
+
mws-ssh guide
|
|
70
|
+
```
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mcp-win-stdio-ssh"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Advanced Multi-SSH Connection & Remote Management Model Context Protocol (MCP) server for Windows & Claude: 25+ tools for multi-host pooling, ~/.ssh/config auto-discovery, PTY interactive shells, SFTP file management, systemd/docker services, background jobs, and local port forwarding tunnels."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
|
|
@@ -30,7 +30,7 @@ classifiers = [
|
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
dependencies = [
|
|
33
|
-
"mcp-win-stdio>=0.
|
|
33
|
+
"mcp-win-stdio>=0.4.0",
|
|
34
34
|
"mcp>=1.2.0",
|
|
35
35
|
"paramiko>=3.0.0",
|
|
36
36
|
"cryptography>=3.3.0",
|
|
@@ -1,365 +1,365 @@
|
|
|
1
|
-
"""
|
|
2
|
-
SSH Connection Pool and Host Configuration Manager for mcp-win-stdio-ssh.
|
|
3
|
-
Handles ~/.ssh/config auto-discovery, persistent host bookmarks, authentication resolution,
|
|
4
|
-
bastion/jump host tunneling, and resilient connection pooling.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import json
|
|
8
|
-
import os
|
|
9
|
-
import sys
|
|
10
|
-
import threading
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Any, Dict, List, Optional
|
|
13
|
-
|
|
14
|
-
import paramiko
|
|
15
|
-
from paramiko.config import SSHConfig
|
|
16
|
-
|
|
17
|
-
from mcp_win_stdio.core.config import USER_DIR, ensure_workspace_dirs
|
|
18
|
-
|
|
19
|
-
SSH_CONFIG_DIR = USER_DIR
|
|
20
|
-
HOSTS_CONFIG_FILE = SSH_CONFIG_DIR / "ssh_hosts.json"
|
|
21
|
-
SSH_USER_DIR = Path.home() / ".ssh"
|
|
22
|
-
SYSTEM_SSH_CONFIG = SSH_USER_DIR / "config"
|
|
23
|
-
|
|
24
|
-
_ACTIVE_HOST: Optional[str] = None
|
|
25
|
-
_CLIENT_POOL: Dict[str, paramiko.SSHClient] = {}
|
|
26
|
-
_POOL_LOCK = threading.RLock()
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _normalize_host_param(host: Any) -> Optional[str]:
|
|
30
|
-
"""Normalize host parameter from LLM tool call, filtering out null/empty strings."""
|
|
31
|
-
if host is None:
|
|
32
|
-
return None
|
|
33
|
-
s = str(host).strip()
|
|
34
|
-
if s.lower() in ("", "null", "none", "undefined", "default"):
|
|
35
|
-
return None
|
|
36
|
-
return s
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def load_ssh_hosts() -> Dict[str, Any]:
|
|
40
|
-
"""Load persistent SSH hosts from ~/.mcp-win-stdio/ssh_hosts.json."""
|
|
41
|
-
ensure_workspace_dirs()
|
|
42
|
-
if not HOSTS_CONFIG_FILE.exists():
|
|
43
|
-
return {}
|
|
44
|
-
try:
|
|
45
|
-
with open(HOSTS_CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
46
|
-
data = json.load(f)
|
|
47
|
-
return data if isinstance(data, dict) else {}
|
|
48
|
-
except Exception:
|
|
49
|
-
return {}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def save_ssh_hosts(data: Dict[str, Any]) -> None:
|
|
53
|
-
"""Save persistent SSH hosts to ~/.mcp-win-stdio/ssh_hosts.json."""
|
|
54
|
-
ensure_workspace_dirs()
|
|
55
|
-
try:
|
|
56
|
-
with open(HOSTS_CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
57
|
-
json.dump(data, f, indent=2)
|
|
58
|
-
except Exception as e:
|
|
59
|
-
sys.stderr.write(f"Warning: Failed to save ssh hosts: {e}\n")
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def get_active_host_name() -> Optional[str]:
|
|
63
|
-
"""Get the currently active host name (memory or persistent)."""
|
|
64
|
-
global _ACTIVE_HOST
|
|
65
|
-
if _ACTIVE_HOST:
|
|
66
|
-
return _ACTIVE_HOST
|
|
67
|
-
data = load_ssh_hosts()
|
|
68
|
-
active = data.get("active_host")
|
|
69
|
-
if active:
|
|
70
|
-
_ACTIVE_HOST = active
|
|
71
|
-
return active
|
|
72
|
-
return None
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def set_active_host_name(name: Optional[str]) -> None:
|
|
76
|
-
"""Set and persist the active host name."""
|
|
77
|
-
global _ACTIVE_HOST
|
|
78
|
-
_ACTIVE_HOST = name
|
|
79
|
-
data = load_ssh_hosts()
|
|
80
|
-
data["active_host"] = name
|
|
81
|
-
save_ssh_hosts(data)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def parse_system_ssh_config() -> Dict[str, Dict[str, Any]]:
|
|
85
|
-
"""Parse ~/.ssh/config and return a mapping of host aliases to connection configs."""
|
|
86
|
-
configs: Dict[str, Dict[str, Any]] = {}
|
|
87
|
-
if not SYSTEM_SSH_CONFIG.exists():
|
|
88
|
-
return configs
|
|
89
|
-
|
|
90
|
-
try:
|
|
91
|
-
ssh_cfg = SSHConfig()
|
|
92
|
-
with open(SYSTEM_SSH_CONFIG, "r", encoding="utf-8", errors="replace") as f:
|
|
93
|
-
ssh_cfg.parse(f)
|
|
94
|
-
|
|
95
|
-
# Iterate through host entries
|
|
96
|
-
for host in ssh_cfg.get_hostnames():
|
|
97
|
-
if host == "*":
|
|
98
|
-
continue
|
|
99
|
-
entry = ssh_cfg.lookup(host)
|
|
100
|
-
identity_files = entry.get("identityfile", [])
|
|
101
|
-
key_path = identity_files[0] if identity_files else None
|
|
102
|
-
|
|
103
|
-
# Resolve ~ in key path
|
|
104
|
-
if key_path:
|
|
105
|
-
key_path = str(Path(key_path).expanduser())
|
|
106
|
-
|
|
107
|
-
configs[host] = {
|
|
108
|
-
"name": host,
|
|
109
|
-
"hostname": entry.get("hostname", host),
|
|
110
|
-
"user": entry.get("user", os.environ.get("USERNAME", "root")),
|
|
111
|
-
"port": int(entry.get("port", 22)),
|
|
112
|
-
"key_path": key_path,
|
|
113
|
-
"proxyjump": entry.get("proxyjump"),
|
|
114
|
-
"proxycommand": entry.get("proxycommand"),
|
|
115
|
-
"source": "ssh_config",
|
|
116
|
-
}
|
|
117
|
-
except Exception as e:
|
|
118
|
-
sys.stderr.write(f"Warning: Failed to parse system SSH config: {e}\n")
|
|
119
|
-
|
|
120
|
-
return configs
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def get_all_registered_hosts() -> Dict[str, Dict[str, Any]]:
|
|
124
|
-
"""Merge SSH config hosts, persistent custom hosts, and env variables."""
|
|
125
|
-
all_hosts: Dict[str, Dict[str, Any]] = {}
|
|
126
|
-
|
|
127
|
-
# 1. System ~/.ssh/config
|
|
128
|
-
system_hosts = parse_system_ssh_config()
|
|
129
|
-
all_hosts.update(system_hosts)
|
|
130
|
-
|
|
131
|
-
# 2. Persistent ~/.mcp-win-stdio/ssh_hosts.json
|
|
132
|
-
saved_data = load_ssh_hosts()
|
|
133
|
-
hosts_dict = saved_data.get("hosts", {})
|
|
134
|
-
for name, host_data in hosts_dict.items():
|
|
135
|
-
if isinstance(host_data, dict):
|
|
136
|
-
entry = dict(host_data)
|
|
137
|
-
entry["name"] = name
|
|
138
|
-
entry["source"] = "saved_config"
|
|
139
|
-
all_hosts[name] = entry
|
|
140
|
-
|
|
141
|
-
# 3. Environment variable SSH_SERVERS (JSON format)
|
|
142
|
-
env_servers = os.environ.get("SSH_SERVERS")
|
|
143
|
-
if env_servers:
|
|
144
|
-
try:
|
|
145
|
-
parsed = json.loads(env_servers)
|
|
146
|
-
if isinstance(parsed, dict):
|
|
147
|
-
for name, cfg in parsed.items():
|
|
148
|
-
if isinstance(cfg, dict):
|
|
149
|
-
entry = dict(cfg)
|
|
150
|
-
entry["name"] = name
|
|
151
|
-
entry["source"] = "env"
|
|
152
|
-
all_hosts[name] = entry
|
|
153
|
-
except Exception:
|
|
154
|
-
pass
|
|
155
|
-
|
|
156
|
-
return all_hosts
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def resolve_host_info(target_name: Optional[str] = None) -> Dict[str, Any]:
|
|
160
|
-
"""
|
|
161
|
-
Resolve connection parameters for a host by name or active default.
|
|
162
|
-
Supports user@host:port strings or registered aliases.
|
|
163
|
-
"""
|
|
164
|
-
norm = _normalize_host_param(target_name)
|
|
165
|
-
host_name = norm or get_active_host_name()
|
|
166
|
-
|
|
167
|
-
registered = get_all_registered_hosts()
|
|
168
|
-
|
|
169
|
-
if host_name and host_name in registered:
|
|
170
|
-
return registered[host_name]
|
|
171
|
-
|
|
172
|
-
# Check if target is a direct user@hostname:port connection string
|
|
173
|
-
if host_name and ("@" in host_name or "." in host_name):
|
|
174
|
-
user = os.environ.get("USERNAME", "root")
|
|
175
|
-
host_str = host_name
|
|
176
|
-
port = 22
|
|
177
|
-
|
|
178
|
-
if "@" in host_str:
|
|
179
|
-
user, host_str = host_str.split("@", 1)
|
|
180
|
-
|
|
181
|
-
if ":" in host_str:
|
|
182
|
-
host_str, port_str = host_str.rsplit(":", 1)
|
|
183
|
-
try:
|
|
184
|
-
port = int(port_str)
|
|
185
|
-
except ValueError:
|
|
186
|
-
port = 22
|
|
187
|
-
|
|
188
|
-
return {
|
|
189
|
-
"name": host_name,
|
|
190
|
-
"hostname": host_str,
|
|
191
|
-
"user": user,
|
|
192
|
-
"port": port,
|
|
193
|
-
"key_path": None,
|
|
194
|
-
"source": "ad_hoc",
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if not host_name:
|
|
198
|
-
if registered:
|
|
199
|
-
first_name = next(iter(registered.keys()))
|
|
200
|
-
set_active_host_name(first_name)
|
|
201
|
-
return registered[first_name]
|
|
202
|
-
raise ValueError(
|
|
203
|
-
"No SSH host specified and no active host is configured. Use 'add_host' or provide 'host' parameter."
|
|
204
|
-
)
|
|
205
|
-
|
|
206
|
-
raise ValueError(f"SSH host '{host_name}' not found. Available hosts: {list(registered.keys())}")
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def _find_default_keys() -> List[str]:
|
|
210
|
-
"""Find default SSH keys in ~/.ssh/ directory."""
|
|
211
|
-
keys = []
|
|
212
|
-
if SSH_USER_DIR.is_dir():
|
|
213
|
-
for key_name in ["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"]:
|
|
214
|
-
kp = SSH_USER_DIR / key_name
|
|
215
|
-
if kp.exists():
|
|
216
|
-
keys.append(str(kp))
|
|
217
|
-
return keys
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def create_ssh_client(
|
|
221
|
-
host_info: Dict[str, Any],
|
|
222
|
-
password: Optional[str] = None,
|
|
223
|
-
passphrase: Optional[str] = None,
|
|
224
|
-
timeout: int = 15,
|
|
225
|
-
) -> paramiko.SSHClient:
|
|
226
|
-
"""Instantiate and connect a paramiko.SSHClient based on host info."""
|
|
227
|
-
client = paramiko.SSHClient()
|
|
228
|
-
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
229
|
-
|
|
230
|
-
hostname = host_info.get("hostname", "localhost")
|
|
231
|
-
port = int(host_info.get("port", 22))
|
|
232
|
-
username = host_info.get("user") or os.environ.get("USERNAME", "root")
|
|
233
|
-
key_path = host_info.get("key_path")
|
|
234
|
-
pwd = password or host_info.get("password")
|
|
235
|
-
pass_phrase = passphrase or host_info.get("passphrase")
|
|
236
|
-
|
|
237
|
-
key_filename = None
|
|
238
|
-
if key_path and Path(key_path).expanduser().exists():
|
|
239
|
-
key_filename = str(Path(key_path).expanduser())
|
|
240
|
-
elif not pwd:
|
|
241
|
-
# Check default keys ONLY if no explicit password was configured
|
|
242
|
-
default_keys = _find_default_keys()
|
|
243
|
-
if default_keys:
|
|
244
|
-
key_filename = default_keys[0]
|
|
245
|
-
|
|
246
|
-
# Support JumpHost / Bastion if specified
|
|
247
|
-
sock = None
|
|
248
|
-
jump_host = host_info.get("jump_host") or host_info.get("proxyjump")
|
|
249
|
-
if jump_host:
|
|
250
|
-
jump_info = resolve_host_info(jump_host)
|
|
251
|
-
jump_client = get_cached_or_connect(jump_info["name"])
|
|
252
|
-
transport = jump_client.get_transport()
|
|
253
|
-
if transport and transport.is_active():
|
|
254
|
-
dest_addr = (hostname, port)
|
|
255
|
-
local_addr = ("127.0.0.1", 0)
|
|
256
|
-
sock = transport.open_channel("direct-tcpip", dest_addr, local_addr)
|
|
257
|
-
|
|
258
|
-
connect_kwargs: Dict[str, Any] = {
|
|
259
|
-
"hostname": hostname,
|
|
260
|
-
"port": port,
|
|
261
|
-
"username": username,
|
|
262
|
-
"timeout": timeout,
|
|
263
|
-
"allow_agent": False if pwd else True,
|
|
264
|
-
"look_for_keys": False if pwd else True,
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if sock:
|
|
268
|
-
connect_kwargs["sock"] = sock
|
|
269
|
-
if pwd:
|
|
270
|
-
connect_kwargs["password"] = pwd
|
|
271
|
-
if key_filename:
|
|
272
|
-
connect_kwargs["key_filename"] = key_filename
|
|
273
|
-
if pass_phrase:
|
|
274
|
-
connect_kwargs["passphrase"] = pass_phrase
|
|
275
|
-
|
|
276
|
-
try:
|
|
277
|
-
client.connect(**connect_kwargs)
|
|
278
|
-
# Enable keepalive
|
|
279
|
-
transport = client.get_transport()
|
|
280
|
-
if transport:
|
|
281
|
-
transport.set_keepalive(30)
|
|
282
|
-
return client
|
|
283
|
-
except Exception as e:
|
|
284
|
-
client.close()
|
|
285
|
-
raise ConnectionError(
|
|
286
|
-
f"Failed to connect to SSH host '{host_info.get('name', hostname)}' ({username}@{hostname}:{port}): {e}"
|
|
287
|
-
)
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def get_cached_or_connect(
|
|
291
|
-
target_name: Optional[str] = None,
|
|
292
|
-
password: Optional[str] = None,
|
|
293
|
-
passphrase: Optional[str] = None,
|
|
294
|
-
timeout: int = 15,
|
|
295
|
-
) -> paramiko.SSHClient:
|
|
296
|
-
"""Retrieve an active SSH client from the pool or establish a new connection."""
|
|
297
|
-
host_info = resolve_host_info(target_name)
|
|
298
|
-
name = host_info["name"]
|
|
299
|
-
|
|
300
|
-
with _POOL_LOCK:
|
|
301
|
-
if name in _CLIENT_POOL:
|
|
302
|
-
client = _CLIENT_POOL[name]
|
|
303
|
-
transport = client.get_transport()
|
|
304
|
-
if transport and transport.is_active():
|
|
305
|
-
return client
|
|
306
|
-
# Drop dead connection
|
|
307
|
-
try:
|
|
308
|
-
client.close()
|
|
309
|
-
except Exception:
|
|
310
|
-
pass
|
|
311
|
-
del _CLIENT_POOL[name]
|
|
312
|
-
|
|
313
|
-
client = create_ssh_client(host_info, password=password, passphrase=passphrase, timeout=timeout)
|
|
314
|
-
_CLIENT_POOL[name] = client
|
|
315
|
-
return client
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def close_connection(target_name: Optional[str] = None) -> bool:
|
|
319
|
-
"""Close connection for a specific host in the pool."""
|
|
320
|
-
norm = _normalize_host_param(target_name)
|
|
321
|
-
name = norm or get_active_host_name()
|
|
322
|
-
if not name:
|
|
323
|
-
return False
|
|
324
|
-
|
|
325
|
-
with _POOL_LOCK:
|
|
326
|
-
if name in _CLIENT_POOL:
|
|
327
|
-
try:
|
|
328
|
-
_CLIENT_POOL[name].close()
|
|
329
|
-
except Exception:
|
|
330
|
-
pass
|
|
331
|
-
del _CLIENT_POOL[name]
|
|
332
|
-
return True
|
|
333
|
-
return False
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def close_all_connections() -> int:
|
|
337
|
-
"""Close all open SSH connections in the pool."""
|
|
338
|
-
closed = 0
|
|
339
|
-
with _POOL_LOCK:
|
|
340
|
-
for name, client in list(_CLIENT_POOL.items()):
|
|
341
|
-
try:
|
|
342
|
-
client.close()
|
|
343
|
-
closed += 1
|
|
344
|
-
except Exception:
|
|
345
|
-
pass
|
|
346
|
-
_CLIENT_POOL.clear()
|
|
347
|
-
return closed
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
def get_pool_status() -> List[Dict[str, Any]]:
|
|
351
|
-
"""Return status of all live connections in the pool."""
|
|
352
|
-
status = []
|
|
353
|
-
with _POOL_LOCK:
|
|
354
|
-
for name, client in _CLIENT_POOL.items():
|
|
355
|
-
transport = client.get_transport()
|
|
356
|
-
is_active = transport.is_active() if transport else False
|
|
357
|
-
status.append(
|
|
358
|
-
{
|
|
359
|
-
"name": name,
|
|
360
|
-
"isActive": is_active,
|
|
361
|
-
"remoteAddress": f"{transport.getpeername()}" if (transport and is_active) else "closed",
|
|
362
|
-
"isCurrentDefault": name == get_active_host_name(),
|
|
363
|
-
}
|
|
364
|
-
)
|
|
365
|
-
return status
|
|
1
|
+
"""
|
|
2
|
+
SSH Connection Pool and Host Configuration Manager for mcp-win-stdio-ssh.
|
|
3
|
+
Handles ~/.ssh/config auto-discovery, persistent host bookmarks, authentication resolution,
|
|
4
|
+
bastion/jump host tunneling, and resilient connection pooling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
import paramiko
|
|
15
|
+
from paramiko.config import SSHConfig
|
|
16
|
+
|
|
17
|
+
from mcp_win_stdio.core.config import USER_DIR, ensure_workspace_dirs
|
|
18
|
+
|
|
19
|
+
SSH_CONFIG_DIR = USER_DIR
|
|
20
|
+
HOSTS_CONFIG_FILE = SSH_CONFIG_DIR / "ssh_hosts.json"
|
|
21
|
+
SSH_USER_DIR = Path.home() / ".ssh"
|
|
22
|
+
SYSTEM_SSH_CONFIG = SSH_USER_DIR / "config"
|
|
23
|
+
|
|
24
|
+
_ACTIVE_HOST: Optional[str] = None
|
|
25
|
+
_CLIENT_POOL: Dict[str, paramiko.SSHClient] = {}
|
|
26
|
+
_POOL_LOCK = threading.RLock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _normalize_host_param(host: Any) -> Optional[str]:
|
|
30
|
+
"""Normalize host parameter from LLM tool call, filtering out null/empty strings."""
|
|
31
|
+
if host is None:
|
|
32
|
+
return None
|
|
33
|
+
s = str(host).strip()
|
|
34
|
+
if s.lower() in ("", "null", "none", "undefined", "default"):
|
|
35
|
+
return None
|
|
36
|
+
return s
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_ssh_hosts() -> Dict[str, Any]:
|
|
40
|
+
"""Load persistent SSH hosts from ~/.mcp-win-stdio/ssh_hosts.json."""
|
|
41
|
+
ensure_workspace_dirs()
|
|
42
|
+
if not HOSTS_CONFIG_FILE.exists():
|
|
43
|
+
return {}
|
|
44
|
+
try:
|
|
45
|
+
with open(HOSTS_CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
46
|
+
data = json.load(f)
|
|
47
|
+
return data if isinstance(data, dict) else {}
|
|
48
|
+
except Exception:
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def save_ssh_hosts(data: Dict[str, Any]) -> None:
|
|
53
|
+
"""Save persistent SSH hosts to ~/.mcp-win-stdio/ssh_hosts.json."""
|
|
54
|
+
ensure_workspace_dirs()
|
|
55
|
+
try:
|
|
56
|
+
with open(HOSTS_CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
57
|
+
json.dump(data, f, indent=2)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
sys.stderr.write(f"Warning: Failed to save ssh hosts: {e}\n")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_active_host_name() -> Optional[str]:
|
|
63
|
+
"""Get the currently active host name (memory or persistent)."""
|
|
64
|
+
global _ACTIVE_HOST
|
|
65
|
+
if _ACTIVE_HOST:
|
|
66
|
+
return _ACTIVE_HOST
|
|
67
|
+
data = load_ssh_hosts()
|
|
68
|
+
active = data.get("active_host")
|
|
69
|
+
if active:
|
|
70
|
+
_ACTIVE_HOST = active
|
|
71
|
+
return active
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def set_active_host_name(name: Optional[str]) -> None:
|
|
76
|
+
"""Set and persist the active host name."""
|
|
77
|
+
global _ACTIVE_HOST
|
|
78
|
+
_ACTIVE_HOST = name
|
|
79
|
+
data = load_ssh_hosts()
|
|
80
|
+
data["active_host"] = name
|
|
81
|
+
save_ssh_hosts(data)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_system_ssh_config() -> Dict[str, Dict[str, Any]]:
|
|
85
|
+
"""Parse ~/.ssh/config and return a mapping of host aliases to connection configs."""
|
|
86
|
+
configs: Dict[str, Dict[str, Any]] = {}
|
|
87
|
+
if not SYSTEM_SSH_CONFIG.exists():
|
|
88
|
+
return configs
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
ssh_cfg = SSHConfig()
|
|
92
|
+
with open(SYSTEM_SSH_CONFIG, "r", encoding="utf-8", errors="replace") as f:
|
|
93
|
+
ssh_cfg.parse(f)
|
|
94
|
+
|
|
95
|
+
# Iterate through host entries
|
|
96
|
+
for host in ssh_cfg.get_hostnames():
|
|
97
|
+
if host == "*":
|
|
98
|
+
continue
|
|
99
|
+
entry = ssh_cfg.lookup(host)
|
|
100
|
+
identity_files = entry.get("identityfile", [])
|
|
101
|
+
key_path = identity_files[0] if identity_files else None
|
|
102
|
+
|
|
103
|
+
# Resolve ~ in key path
|
|
104
|
+
if key_path:
|
|
105
|
+
key_path = str(Path(key_path).expanduser())
|
|
106
|
+
|
|
107
|
+
configs[host] = {
|
|
108
|
+
"name": host,
|
|
109
|
+
"hostname": entry.get("hostname", host),
|
|
110
|
+
"user": entry.get("user", os.environ.get("USERNAME", "root")),
|
|
111
|
+
"port": int(entry.get("port", 22)),
|
|
112
|
+
"key_path": key_path,
|
|
113
|
+
"proxyjump": entry.get("proxyjump"),
|
|
114
|
+
"proxycommand": entry.get("proxycommand"),
|
|
115
|
+
"source": "ssh_config",
|
|
116
|
+
}
|
|
117
|
+
except Exception as e:
|
|
118
|
+
sys.stderr.write(f"Warning: Failed to parse system SSH config: {e}\n")
|
|
119
|
+
|
|
120
|
+
return configs
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_all_registered_hosts() -> Dict[str, Dict[str, Any]]:
|
|
124
|
+
"""Merge SSH config hosts, persistent custom hosts, and env variables."""
|
|
125
|
+
all_hosts: Dict[str, Dict[str, Any]] = {}
|
|
126
|
+
|
|
127
|
+
# 1. System ~/.ssh/config
|
|
128
|
+
system_hosts = parse_system_ssh_config()
|
|
129
|
+
all_hosts.update(system_hosts)
|
|
130
|
+
|
|
131
|
+
# 2. Persistent ~/.mcp-win-stdio/ssh_hosts.json
|
|
132
|
+
saved_data = load_ssh_hosts()
|
|
133
|
+
hosts_dict = saved_data.get("hosts", {})
|
|
134
|
+
for name, host_data in hosts_dict.items():
|
|
135
|
+
if isinstance(host_data, dict):
|
|
136
|
+
entry = dict(host_data)
|
|
137
|
+
entry["name"] = name
|
|
138
|
+
entry["source"] = "saved_config"
|
|
139
|
+
all_hosts[name] = entry
|
|
140
|
+
|
|
141
|
+
# 3. Environment variable SSH_SERVERS (JSON format)
|
|
142
|
+
env_servers = os.environ.get("SSH_SERVERS")
|
|
143
|
+
if env_servers:
|
|
144
|
+
try:
|
|
145
|
+
parsed = json.loads(env_servers)
|
|
146
|
+
if isinstance(parsed, dict):
|
|
147
|
+
for name, cfg in parsed.items():
|
|
148
|
+
if isinstance(cfg, dict):
|
|
149
|
+
entry = dict(cfg)
|
|
150
|
+
entry["name"] = name
|
|
151
|
+
entry["source"] = "env"
|
|
152
|
+
all_hosts[name] = entry
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
return all_hosts
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_host_info(target_name: Optional[str] = None) -> Dict[str, Any]:
|
|
160
|
+
"""
|
|
161
|
+
Resolve connection parameters for a host by name or active default.
|
|
162
|
+
Supports user@host:port strings or registered aliases.
|
|
163
|
+
"""
|
|
164
|
+
norm = _normalize_host_param(target_name)
|
|
165
|
+
host_name = norm or get_active_host_name()
|
|
166
|
+
|
|
167
|
+
registered = get_all_registered_hosts()
|
|
168
|
+
|
|
169
|
+
if host_name and host_name in registered:
|
|
170
|
+
return registered[host_name]
|
|
171
|
+
|
|
172
|
+
# Check if target is a direct user@hostname:port connection string
|
|
173
|
+
if host_name and ("@" in host_name or "." in host_name):
|
|
174
|
+
user = os.environ.get("USERNAME", "root")
|
|
175
|
+
host_str = host_name
|
|
176
|
+
port = 22
|
|
177
|
+
|
|
178
|
+
if "@" in host_str:
|
|
179
|
+
user, host_str = host_str.split("@", 1)
|
|
180
|
+
|
|
181
|
+
if ":" in host_str:
|
|
182
|
+
host_str, port_str = host_str.rsplit(":", 1)
|
|
183
|
+
try:
|
|
184
|
+
port = int(port_str)
|
|
185
|
+
except ValueError:
|
|
186
|
+
port = 22
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
"name": host_name,
|
|
190
|
+
"hostname": host_str,
|
|
191
|
+
"user": user,
|
|
192
|
+
"port": port,
|
|
193
|
+
"key_path": None,
|
|
194
|
+
"source": "ad_hoc",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if not host_name:
|
|
198
|
+
if registered:
|
|
199
|
+
first_name = next(iter(registered.keys()))
|
|
200
|
+
set_active_host_name(first_name)
|
|
201
|
+
return registered[first_name]
|
|
202
|
+
raise ValueError(
|
|
203
|
+
"No SSH host specified and no active host is configured. Use 'add_host' or provide 'host' parameter."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
raise ValueError(f"SSH host '{host_name}' not found. Available hosts: {list(registered.keys())}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _find_default_keys() -> List[str]:
|
|
210
|
+
"""Find default SSH keys in ~/.ssh/ directory."""
|
|
211
|
+
keys = []
|
|
212
|
+
if SSH_USER_DIR.is_dir():
|
|
213
|
+
for key_name in ["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"]:
|
|
214
|
+
kp = SSH_USER_DIR / key_name
|
|
215
|
+
if kp.exists():
|
|
216
|
+
keys.append(str(kp))
|
|
217
|
+
return keys
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def create_ssh_client(
|
|
221
|
+
host_info: Dict[str, Any],
|
|
222
|
+
password: Optional[str] = None,
|
|
223
|
+
passphrase: Optional[str] = None,
|
|
224
|
+
timeout: int = 15,
|
|
225
|
+
) -> paramiko.SSHClient:
|
|
226
|
+
"""Instantiate and connect a paramiko.SSHClient based on host info."""
|
|
227
|
+
client = paramiko.SSHClient()
|
|
228
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
229
|
+
|
|
230
|
+
hostname = host_info.get("hostname", "localhost")
|
|
231
|
+
port = int(host_info.get("port", 22))
|
|
232
|
+
username = host_info.get("user") or os.environ.get("USERNAME", "root")
|
|
233
|
+
key_path = host_info.get("key_path")
|
|
234
|
+
pwd = password or host_info.get("password")
|
|
235
|
+
pass_phrase = passphrase or host_info.get("passphrase")
|
|
236
|
+
|
|
237
|
+
key_filename = None
|
|
238
|
+
if key_path and Path(key_path).expanduser().exists():
|
|
239
|
+
key_filename = str(Path(key_path).expanduser())
|
|
240
|
+
elif not pwd:
|
|
241
|
+
# Check default keys ONLY if no explicit password was configured
|
|
242
|
+
default_keys = _find_default_keys()
|
|
243
|
+
if default_keys:
|
|
244
|
+
key_filename = default_keys[0]
|
|
245
|
+
|
|
246
|
+
# Support JumpHost / Bastion if specified
|
|
247
|
+
sock = None
|
|
248
|
+
jump_host = host_info.get("jump_host") or host_info.get("proxyjump")
|
|
249
|
+
if jump_host:
|
|
250
|
+
jump_info = resolve_host_info(jump_host)
|
|
251
|
+
jump_client = get_cached_or_connect(jump_info["name"])
|
|
252
|
+
transport = jump_client.get_transport()
|
|
253
|
+
if transport and transport.is_active():
|
|
254
|
+
dest_addr = (hostname, port)
|
|
255
|
+
local_addr = ("127.0.0.1", 0)
|
|
256
|
+
sock = transport.open_channel("direct-tcpip", dest_addr, local_addr)
|
|
257
|
+
|
|
258
|
+
connect_kwargs: Dict[str, Any] = {
|
|
259
|
+
"hostname": hostname,
|
|
260
|
+
"port": port,
|
|
261
|
+
"username": username,
|
|
262
|
+
"timeout": timeout,
|
|
263
|
+
"allow_agent": False if pwd else True,
|
|
264
|
+
"look_for_keys": False if pwd else True,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if sock:
|
|
268
|
+
connect_kwargs["sock"] = sock
|
|
269
|
+
if pwd:
|
|
270
|
+
connect_kwargs["password"] = pwd
|
|
271
|
+
if key_filename:
|
|
272
|
+
connect_kwargs["key_filename"] = key_filename
|
|
273
|
+
if pass_phrase:
|
|
274
|
+
connect_kwargs["passphrase"] = pass_phrase
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
client.connect(**connect_kwargs)
|
|
278
|
+
# Enable keepalive
|
|
279
|
+
transport = client.get_transport()
|
|
280
|
+
if transport:
|
|
281
|
+
transport.set_keepalive(30)
|
|
282
|
+
return client
|
|
283
|
+
except Exception as e:
|
|
284
|
+
client.close()
|
|
285
|
+
raise ConnectionError(
|
|
286
|
+
f"Failed to connect to SSH host '{host_info.get('name', hostname)}' ({username}@{hostname}:{port}): {e}"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def get_cached_or_connect(
|
|
291
|
+
target_name: Optional[str] = None,
|
|
292
|
+
password: Optional[str] = None,
|
|
293
|
+
passphrase: Optional[str] = None,
|
|
294
|
+
timeout: int = 15,
|
|
295
|
+
) -> paramiko.SSHClient:
|
|
296
|
+
"""Retrieve an active SSH client from the pool or establish a new connection."""
|
|
297
|
+
host_info = resolve_host_info(target_name)
|
|
298
|
+
name = host_info["name"]
|
|
299
|
+
|
|
300
|
+
with _POOL_LOCK:
|
|
301
|
+
if name in _CLIENT_POOL:
|
|
302
|
+
client = _CLIENT_POOL[name]
|
|
303
|
+
transport = client.get_transport()
|
|
304
|
+
if transport and transport.is_active():
|
|
305
|
+
return client
|
|
306
|
+
# Drop dead connection
|
|
307
|
+
try:
|
|
308
|
+
client.close()
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
del _CLIENT_POOL[name]
|
|
312
|
+
|
|
313
|
+
client = create_ssh_client(host_info, password=password, passphrase=passphrase, timeout=timeout)
|
|
314
|
+
_CLIENT_POOL[name] = client
|
|
315
|
+
return client
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def close_connection(target_name: Optional[str] = None) -> bool:
|
|
319
|
+
"""Close connection for a specific host in the pool."""
|
|
320
|
+
norm = _normalize_host_param(target_name)
|
|
321
|
+
name = norm or get_active_host_name()
|
|
322
|
+
if not name:
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
with _POOL_LOCK:
|
|
326
|
+
if name in _CLIENT_POOL:
|
|
327
|
+
try:
|
|
328
|
+
_CLIENT_POOL[name].close()
|
|
329
|
+
except Exception:
|
|
330
|
+
pass
|
|
331
|
+
del _CLIENT_POOL[name]
|
|
332
|
+
return True
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def close_all_connections() -> int:
|
|
337
|
+
"""Close all open SSH connections in the pool."""
|
|
338
|
+
closed = 0
|
|
339
|
+
with _POOL_LOCK:
|
|
340
|
+
for name, client in list(_CLIENT_POOL.items()):
|
|
341
|
+
try:
|
|
342
|
+
client.close()
|
|
343
|
+
closed += 1
|
|
344
|
+
except Exception:
|
|
345
|
+
pass
|
|
346
|
+
_CLIENT_POOL.clear()
|
|
347
|
+
return closed
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def get_pool_status() -> List[Dict[str, Any]]:
|
|
351
|
+
"""Return status of all live connections in the pool."""
|
|
352
|
+
status = []
|
|
353
|
+
with _POOL_LOCK:
|
|
354
|
+
for name, client in _CLIENT_POOL.items():
|
|
355
|
+
transport = client.get_transport()
|
|
356
|
+
is_active = transport.is_active() if transport else False
|
|
357
|
+
status.append(
|
|
358
|
+
{
|
|
359
|
+
"name": name,
|
|
360
|
+
"isActive": is_active,
|
|
361
|
+
"remoteAddress": f"{transport.getpeername()}" if (transport and is_active) else "closed",
|
|
362
|
+
"isCurrentDefault": name == get_active_host_name(),
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
return status
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|