sshand 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- host_config.py +191 -0
- platform_utils.py +179 -0
- server.py +941 -0
- setup_wizard.py +494 -0
- ssh_client.py +380 -0
- sshand-0.1.0.dist-info/METADATA +344 -0
- sshand-0.1.0.dist-info/RECORD +11 -0
- sshand-0.1.0.dist-info/WHEEL +5 -0
- sshand-0.1.0.dist-info/entry_points.txt +2 -0
- sshand-0.1.0.dist-info/licenses/LICENSE +21 -0
- sshand-0.1.0.dist-info/top_level.txt +5 -0
host_config.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
host_config.py — SSH host inventory management.
|
|
4
|
+
|
|
5
|
+
Reads and writes a YAML file (hosts.yaml by default) that stores named SSH
|
|
6
|
+
targets with their connection and authentication details. Three auth types
|
|
7
|
+
are supported:
|
|
8
|
+
- key : private-key file on disk
|
|
9
|
+
- password : plaintext password (stored in the YAML – handle with care)
|
|
10
|
+
- agent : delegate to the running ssh-agent process
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, Literal, Optional
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Default location for the hosts inventory file
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
_DEFAULT_HOSTS_FILE = Path(
|
|
27
|
+
os.environ.get("SSH_MCP_HOSTS_FILE", str(Path(__file__).parent / "hosts.yaml"))
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Pydantic models
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class KeyAuth(BaseModel):
|
|
37
|
+
"""Authentication via a private-key file."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
40
|
+
|
|
41
|
+
type: Literal["key"] = "key"
|
|
42
|
+
key_path: str = Field(
|
|
43
|
+
...,
|
|
44
|
+
description=(
|
|
45
|
+
"Absolute or ~-relative path to the private key file "
|
|
46
|
+
"(e.g., '~/.ssh/id_rsa', '/home/user/.ssh/deploy_key')."
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
passphrase: Optional[str] = Field(
|
|
50
|
+
default=None,
|
|
51
|
+
description="Optional passphrase to decrypt the private key.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@field_validator("key_path")
|
|
55
|
+
@classmethod
|
|
56
|
+
def expand_path(cls, v: str) -> str:
|
|
57
|
+
return str(Path(v).expanduser())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PasswordAuth(BaseModel):
|
|
61
|
+
"""Authentication via username + password."""
|
|
62
|
+
|
|
63
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
64
|
+
|
|
65
|
+
type: Literal["password"] = "password"
|
|
66
|
+
password: str = Field(..., description="SSH password for the user.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AgentAuth(BaseModel):
|
|
70
|
+
"""Authentication via the local ssh-agent."""
|
|
71
|
+
|
|
72
|
+
model_config = ConfigDict(extra="forbid")
|
|
73
|
+
|
|
74
|
+
type: Literal["agent"] = "agent"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
AuthConfig = KeyAuth | PasswordAuth | AgentAuth
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class HostEntry(BaseModel):
|
|
81
|
+
"""A single SSH host in the inventory."""
|
|
82
|
+
|
|
83
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
84
|
+
|
|
85
|
+
hostname: str = Field(
|
|
86
|
+
...,
|
|
87
|
+
description="IP address or fully-qualified domain name of the host.",
|
|
88
|
+
)
|
|
89
|
+
port: int = Field(default=22, ge=1, le=65535, description="SSH port (default 22).")
|
|
90
|
+
username: str = Field(
|
|
91
|
+
...,
|
|
92
|
+
description="SSH username (e.g., 'ubuntu', 'ec2-user', 'root').",
|
|
93
|
+
)
|
|
94
|
+
auth: AuthConfig = Field(
|
|
95
|
+
...,
|
|
96
|
+
description="Authentication config. Use type='key', 'password', or 'agent'.",
|
|
97
|
+
)
|
|
98
|
+
description: Optional[str] = Field(
|
|
99
|
+
default=None, description="Human-readable note about this host."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@field_validator("hostname")
|
|
103
|
+
@classmethod
|
|
104
|
+
def hostname_not_empty(cls, v: str) -> str:
|
|
105
|
+
if not v.strip():
|
|
106
|
+
raise ValueError("hostname must not be empty")
|
|
107
|
+
return v
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Inventory helpers
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _load_raw(hosts_file: Path) -> Dict[str, Any]:
|
|
116
|
+
"""Return the raw YAML dict, creating an empty one if the file is absent."""
|
|
117
|
+
if not hosts_file.exists():
|
|
118
|
+
return {"hosts": {}}
|
|
119
|
+
with hosts_file.open("r", encoding="utf-8") as fh:
|
|
120
|
+
data = yaml.safe_load(fh) or {}
|
|
121
|
+
if "hosts" not in data:
|
|
122
|
+
data["hosts"] = {}
|
|
123
|
+
return data
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _save_raw(data: Dict[str, Any], hosts_file: Path) -> None:
|
|
127
|
+
hosts_file.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
with hosts_file.open("w", encoding="utf-8") as fh:
|
|
129
|
+
yaml.dump(data, fh, default_flow_style=False, allow_unicode=True, sort_keys=True)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def list_hosts(hosts_file: Path = _DEFAULT_HOSTS_FILE) -> Dict[str, HostEntry]:
|
|
133
|
+
"""
|
|
134
|
+
Return all hosts from the inventory as a dict of {alias: HostEntry}.
|
|
135
|
+
|
|
136
|
+
Returns an empty dict if the inventory file does not exist yet.
|
|
137
|
+
"""
|
|
138
|
+
data = _load_raw(hosts_file)
|
|
139
|
+
result: Dict[str, HostEntry] = {}
|
|
140
|
+
for alias, raw in data["hosts"].items():
|
|
141
|
+
try:
|
|
142
|
+
result[alias] = HostEntry.model_validate(raw)
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
# Skip malformed entries but don't crash
|
|
145
|
+
result[alias] = exc # type: ignore[assignment]
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_host(alias: str, hosts_file: Path = _DEFAULT_HOSTS_FILE) -> HostEntry:
|
|
150
|
+
"""
|
|
151
|
+
Retrieve a single host by alias.
|
|
152
|
+
|
|
153
|
+
Raises KeyError if not found, ValueError if the entry is malformed.
|
|
154
|
+
"""
|
|
155
|
+
data = _load_raw(hosts_file)
|
|
156
|
+
if alias not in data["hosts"]:
|
|
157
|
+
raise KeyError(f"Host '{alias}' not found in inventory.")
|
|
158
|
+
return HostEntry.model_validate(data["hosts"][alias])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def add_host(
|
|
162
|
+
alias: str,
|
|
163
|
+
entry: HostEntry,
|
|
164
|
+
overwrite: bool = False,
|
|
165
|
+
hosts_file: Path = _DEFAULT_HOSTS_FILE,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Add (or replace) a host in the inventory.
|
|
169
|
+
|
|
170
|
+
Raises ValueError if the alias already exists and overwrite=False.
|
|
171
|
+
"""
|
|
172
|
+
data = _load_raw(hosts_file)
|
|
173
|
+
if alias in data["hosts"] and not overwrite:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"Host '{alias}' already exists. Pass overwrite=True to replace it."
|
|
176
|
+
)
|
|
177
|
+
data["hosts"][alias] = entry.model_dump()
|
|
178
|
+
_save_raw(data, hosts_file)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def remove_host(alias: str, hosts_file: Path = _DEFAULT_HOSTS_FILE) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Remove a host from the inventory.
|
|
184
|
+
|
|
185
|
+
Raises KeyError if the alias does not exist.
|
|
186
|
+
"""
|
|
187
|
+
data = _load_raw(hosts_file)
|
|
188
|
+
if alias not in data["hosts"]:
|
|
189
|
+
raise KeyError(f"Host '{alias}' not found in inventory.")
|
|
190
|
+
del data["hosts"][alias]
|
|
191
|
+
_save_raw(data, hosts_file)
|
platform_utils.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
platform_utils.py -- Windows-specific SSH agent helpers.
|
|
4
|
+
|
|
5
|
+
On macOS/Linux the ssh-agent is managed by the OS or the user's shell
|
|
6
|
+
profile and just works. On Windows it is an optional service that ships
|
|
7
|
+
with the built-in OpenSSH client but is disabled by default.
|
|
8
|
+
|
|
9
|
+
This module lets setup_wizard.py and ssh_client.py:
|
|
10
|
+
- Detect whether the current platform is Windows
|
|
11
|
+
- Check whether the OpenSSH Authentication Agent service is running
|
|
12
|
+
- Attempt to enable + start it (requires Administrator privileges)
|
|
13
|
+
- Surface clear, actionable instructions when it cannot be auto-fixed
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from enum import Enum, auto
|
|
21
|
+
from typing import Tuple
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Platform detection
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
IS_WINDOWS: bool = sys.platform == "win32"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Service status
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
class AgentStatus(Enum):
|
|
36
|
+
NOT_WINDOWS = auto() # macOS / Linux -- no check needed
|
|
37
|
+
RUNNING = auto() # service is active and ready
|
|
38
|
+
STOPPED = auto() # service exists but is not running
|
|
39
|
+
DISABLED = auto() # service start type is set to Disabled
|
|
40
|
+
NOT_INSTALLED = auto() # OpenSSH not installed at all
|
|
41
|
+
UNKNOWN = auto() # could not determine (non-admin, etc.)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# PowerShell snippet shown to users who need to fix the service manually
|
|
45
|
+
WINDOWS_FIX_INSTRUCTIONS = """\
|
|
46
|
+
Run the following in PowerShell as Administrator:
|
|
47
|
+
|
|
48
|
+
Set-Service ssh-agent -StartupType Automatic
|
|
49
|
+
Start-Service ssh-agent
|
|
50
|
+
ssh-add # optionally load your key into the agent
|
|
51
|
+
|
|
52
|
+
To open an elevated PowerShell:
|
|
53
|
+
Press Win+X -> "Windows PowerShell (Admin)" or "Terminal (Admin)"
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_agent_status() -> AgentStatus:
|
|
58
|
+
"""
|
|
59
|
+
Query the Windows OpenSSH Authentication Agent service state.
|
|
60
|
+
|
|
61
|
+
Returns AgentStatus.NOT_WINDOWS on non-Windows platforms so callers
|
|
62
|
+
can use this unconditionally without a platform guard.
|
|
63
|
+
"""
|
|
64
|
+
if not IS_WINDOWS:
|
|
65
|
+
return AgentStatus.NOT_WINDOWS
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
["sc", "query", "ssh-agent"],
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
timeout=5,
|
|
73
|
+
)
|
|
74
|
+
output = result.stdout.upper()
|
|
75
|
+
|
|
76
|
+
if result.returncode != 0:
|
|
77
|
+
# Service not found
|
|
78
|
+
if "DOES NOT EXIST" in output or "1060" in result.stdout:
|
|
79
|
+
return AgentStatus.NOT_INSTALLED
|
|
80
|
+
return AgentStatus.UNKNOWN
|
|
81
|
+
|
|
82
|
+
if "RUNNING" in output:
|
|
83
|
+
return AgentStatus.RUNNING
|
|
84
|
+
|
|
85
|
+
# Check start type for DISABLED
|
|
86
|
+
config_result = subprocess.run(
|
|
87
|
+
["sc", "qc", "ssh-agent"],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
timeout=5,
|
|
91
|
+
)
|
|
92
|
+
if "DISABLED" in config_result.stdout.upper():
|
|
93
|
+
return AgentStatus.DISABLED
|
|
94
|
+
|
|
95
|
+
return AgentStatus.STOPPED
|
|
96
|
+
|
|
97
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
98
|
+
return AgentStatus.UNKNOWN
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_windows_admin() -> bool:
|
|
102
|
+
"""Return True if the current process has Administrator privileges on Windows."""
|
|
103
|
+
if not IS_WINDOWS:
|
|
104
|
+
return False
|
|
105
|
+
try:
|
|
106
|
+
import ctypes
|
|
107
|
+
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
|
108
|
+
except Exception:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def start_agent_service() -> Tuple[bool, str]:
|
|
113
|
+
"""
|
|
114
|
+
Attempt to set the ssh-agent service to Automatic and start it.
|
|
115
|
+
|
|
116
|
+
Must be called with Administrator privileges. Returns (success, message).
|
|
117
|
+
"""
|
|
118
|
+
if not IS_WINDOWS:
|
|
119
|
+
return False, "Not running on Windows."
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
subprocess.run(
|
|
123
|
+
["sc", "config", "ssh-agent", "start=auto"],
|
|
124
|
+
check=True, capture_output=True, timeout=10,
|
|
125
|
+
)
|
|
126
|
+
subprocess.run(
|
|
127
|
+
["net", "start", "ssh-agent"],
|
|
128
|
+
check=True, capture_output=True, timeout=10,
|
|
129
|
+
)
|
|
130
|
+
# Verify it actually started
|
|
131
|
+
if get_agent_status() == AgentStatus.RUNNING:
|
|
132
|
+
return True, "OpenSSH Authentication Agent service started successfully."
|
|
133
|
+
return False, "Service start command ran but the service is not RUNNING."
|
|
134
|
+
except subprocess.CalledProcessError as exc:
|
|
135
|
+
stderr = exc.stderr.decode(errors="replace") if exc.stderr else ""
|
|
136
|
+
return False, f"sc/net command failed: {stderr.strip() or exc}"
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
return False, f"Unexpected error: {exc}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def agent_status_message(status: AgentStatus) -> str:
|
|
142
|
+
"""
|
|
143
|
+
Return a human-readable, actionable description of the given AgentStatus.
|
|
144
|
+
Suitable for printing in the wizard or as an MCP error message.
|
|
145
|
+
"""
|
|
146
|
+
if status == AgentStatus.NOT_WINDOWS:
|
|
147
|
+
return "" # nothing to report on mac/linux
|
|
148
|
+
|
|
149
|
+
if status == AgentStatus.RUNNING:
|
|
150
|
+
return "OpenSSH Authentication Agent is running. Agent auth will work."
|
|
151
|
+
|
|
152
|
+
if status == AgentStatus.STOPPED:
|
|
153
|
+
return (
|
|
154
|
+
"OpenSSH Authentication Agent service exists but is not running.\n"
|
|
155
|
+
+ WINDOWS_FIX_INSTRUCTIONS
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if status == AgentStatus.DISABLED:
|
|
159
|
+
return (
|
|
160
|
+
"OpenSSH Authentication Agent service is disabled.\n"
|
|
161
|
+
+ WINDOWS_FIX_INSTRUCTIONS
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if status == AgentStatus.NOT_INSTALLED:
|
|
165
|
+
return (
|
|
166
|
+
"OpenSSH does not appear to be installed on this Windows machine.\n\n"
|
|
167
|
+
" Install it via Settings -> Apps -> Optional Features -> OpenSSH Client\n"
|
|
168
|
+
" or in PowerShell (Admin):\n\n"
|
|
169
|
+
" Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0\n\n"
|
|
170
|
+
" Then run:\n"
|
|
171
|
+
" Set-Service ssh-agent -StartupType Automatic\n"
|
|
172
|
+
" Start-Service ssh-agent\n"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# UNKNOWN
|
|
176
|
+
return (
|
|
177
|
+
"Could not determine the state of the OpenSSH Authentication Agent service.\n"
|
|
178
|
+
+ WINDOWS_FIX_INSTRUCTIONS
|
|
179
|
+
)
|