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 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
+ )