ssh-server-manager 0.2.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.
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import stat
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ APP_NAME = "ssh-server-manager"
10
+
11
+
12
+ def skill_root() -> Path:
13
+ return Path(__file__).resolve().parents[2]
14
+
15
+
16
+ def data_dir() -> Path:
17
+ override = os.environ.get("SSM_DATA_DIR")
18
+ if override:
19
+ return Path(override).expanduser().resolve()
20
+ try:
21
+ from platformdirs import user_data_path
22
+
23
+ return Path(user_data_path(APP_NAME, appauthor=False))
24
+ except ImportError:
25
+ home = Path.home()
26
+ if sys.platform == "darwin":
27
+ return home / "Library" / "Application Support" / APP_NAME
28
+ if os.name == "nt":
29
+ return Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local")) / APP_NAME
30
+ return Path(os.environ.get("XDG_DATA_HOME", home / ".local" / "share")) / APP_NAME
31
+
32
+
33
+ def ensure_private_dir(path: Path) -> Path:
34
+ if path.exists() or path.is_symlink():
35
+ info = path.lstat()
36
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
37
+ raise RuntimeError(f"private data path is not a real directory: {path}")
38
+ if os.name != "nt" and info.st_uid != os.getuid():
39
+ raise RuntimeError(f"private data directory is owned by another user: {path}")
40
+ else:
41
+ path.mkdir(parents=True, mode=stat.S_IRWXU)
42
+ if os.name != "nt":
43
+ current_mode = stat.S_IMODE(path.stat().st_mode)
44
+ if current_mode != stat.S_IRWXU:
45
+ path.chmod(stat.S_IRWXU)
46
+ return path
47
+
48
+
49
+ def database_path() -> Path:
50
+ return data_dir() / "manager.db"
51
+
52
+
53
+ def runtime_dir() -> Path:
54
+ override = os.environ.get("SSM_RUNTIME_DIR")
55
+ if override:
56
+ base = Path(override).expanduser()
57
+ elif os.name == "nt":
58
+ base = data_dir() / "runtime"
59
+ else:
60
+ # Keep this path short: OpenSSH limits Unix-domain ControlPath length.
61
+ base = Path("/tmp") / f"ssm-{os.getuid()}"
62
+ return ensure_private_dir(base.resolve())
63
+
64
+
65
+ def original_ssh_config_path() -> Path:
66
+ override = os.environ.get("SSM_ORIGINAL_SSH_CONFIG")
67
+ if override:
68
+ return Path(override).expanduser().resolve()
69
+ return Path.home() / ".ssh" / "config"
70
+
71
+
72
+ def managed_ssh_config_path() -> Path:
73
+ override = os.environ.get("SSM_MANAGED_SSH_CONFIG")
74
+ if override:
75
+ return Path(override).expanduser().resolve()
76
+ return Path.home() / ".ssh" / "ssh-server-manager.conf"
77
+
78
+
79
+ def asset_dir() -> Path:
80
+ return Path(__file__).resolve().parent / "assets" / "ui"
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .db import Database
8
+ from .vault import VaultProtocol
9
+
10
+
11
+ class CredentialService:
12
+ def __init__(self, database: Database, vault: VaultProtocol) -> None:
13
+ self.database = database
14
+ self.vault = vault
15
+
16
+ def create_password(self, label: str, secret: str) -> dict[str, Any]:
17
+ identifier = str(uuid.uuid4())
18
+ credential = self.database.create_credential(
19
+ credential_id=identifier, label=label, kind="password", has_secret=False
20
+ )
21
+ try:
22
+ self.vault.set_secret(identifier, "password", secret)
23
+ return self.database.update_credential(identifier, has_secret=True)
24
+ except Exception:
25
+ self.vault.delete_secret(identifier, "password")
26
+ self.database.delete_credential(identifier)
27
+ raise
28
+
29
+ def create_key(self, label: str, key_path: str, passphrase: str | None = None) -> dict[str, Any]:
30
+ identifier = str(uuid.uuid4())
31
+ credential = self.database.create_credential(
32
+ credential_id=identifier,
33
+ label=label,
34
+ kind="key",
35
+ key_path=key_path,
36
+ has_passphrase=False,
37
+ )
38
+ if not passphrase:
39
+ return credential
40
+ try:
41
+ self.vault.set_secret(identifier, "passphrase", passphrase)
42
+ return self.database.update_credential(identifier, has_passphrase=True)
43
+ except Exception:
44
+ self.vault.delete_secret(identifier, "passphrase")
45
+ self.database.delete_credential(identifier)
46
+ raise
47
+
48
+ def create_agent(self, label: str) -> dict[str, Any]:
49
+ return self.database.create_credential(label=label, kind="agent")
50
+
51
+ def update(
52
+ self,
53
+ identifier: str,
54
+ *,
55
+ label: str | None = None,
56
+ key_path: str | None = None,
57
+ secret: str | None = None,
58
+ passphrase: str | None = None,
59
+ clear_passphrase: bool = False,
60
+ ) -> dict[str, Any]:
61
+ credential = self.database.get_credential(identifier)
62
+ changes: dict[str, Any] = {}
63
+ if label is not None:
64
+ changes["label"] = label
65
+ if key_path is not None:
66
+ changes["key_path"] = key_path
67
+ if secret is not None:
68
+ if credential["kind"] != "password":
69
+ raise ValueError("only password credentials have a password secret")
70
+ self.vault.set_secret(credential["id"], "password", secret)
71
+ changes["has_secret"] = True
72
+ if passphrase is not None:
73
+ if credential["kind"] != "key":
74
+ raise ValueError("only key credentials have a passphrase")
75
+ self.vault.set_secret(credential["id"], "passphrase", passphrase)
76
+ changes["has_passphrase"] = True
77
+ if clear_passphrase:
78
+ self.vault.delete_secret(credential["id"], "passphrase")
79
+ changes["has_passphrase"] = False
80
+ return self.database.update_credential(credential["id"], **changes) if changes else credential
81
+
82
+ def delete(self, identifier: str) -> dict[str, Any]:
83
+ credential = self.database.get_credential(identifier)
84
+ # The database enforces that referenced credentials cannot be removed.
85
+ removed = self.database.delete_credential(credential["id"])
86
+ self.vault.delete_secret(credential["id"], "password")
87
+ self.vault.delete_secret(credential["id"], "passphrase")
88
+ return removed
89
+
90
+ def reveal(self, identifier: str) -> dict[str, Any]:
91
+ credential = self.database.get_credential(identifier)
92
+ if credential["kind"] == "password":
93
+ slot = "password"
94
+ elif credential["kind"] == "key" and credential["has_passphrase"]:
95
+ slot = "passphrase"
96
+ else:
97
+ raise ValueError("this credential has no revealable secret")
98
+ value = self.vault.get_secret(credential["id"], slot)
99
+ if value is None:
100
+ raise ValueError("the OS credential vault has no matching secret")
101
+ return {"credential_id": credential["id"], "slot": slot, "value": value}
102
+
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import glob
4
+ import os
5
+ import shlex
6
+ import stat
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Iterable
10
+
11
+ from .db import Database
12
+ from .paths import managed_ssh_config_path, original_ssh_config_path
13
+
14
+
15
+ class SSHConfigError(RuntimeError):
16
+ pass
17
+
18
+
19
+ def quote_value(value: str) -> str:
20
+ if not value or any(ch.isspace() or ch in {'"', "'", "#", "\\"} for ch in value):
21
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
22
+ return value
23
+
24
+
25
+ def split_config_line(line: str) -> list[str]:
26
+ """Tokenize one OpenSSH config line.
27
+
28
+ On Windows a backslash is a path separator, not an escape character, so
29
+ it is preserved before POSIX-style quote handling is applied.
30
+ """
31
+ if os.name == "nt":
32
+ line = line.replace("\\", "\\\\")
33
+ try:
34
+ return shlex.split(line, comments=True, posix=True)
35
+ except ValueError:
36
+ return []
37
+
38
+
39
+ def _parse_include_tokens(line: str) -> list[str]:
40
+ tokens = split_config_line(line)
41
+ if not tokens or tokens[0].casefold() != "include":
42
+ return []
43
+ return tokens[1:]
44
+
45
+
46
+ def _expand_include(pattern: str, parent: Path) -> list[Path]:
47
+ expanded = os.path.expanduser(pattern)
48
+ path = Path(expanded)
49
+ if not path.is_absolute():
50
+ path = parent / path
51
+ return [Path(item).resolve() for item in sorted(glob.glob(str(path)))]
52
+
53
+
54
+ def includes_target(config: Path, target: Path, seen: set[Path] | None = None) -> bool:
55
+ config = config.expanduser().resolve()
56
+ target = target.expanduser().resolve()
57
+ if not config.exists():
58
+ return False
59
+ seen = seen or set()
60
+ if config in seen:
61
+ return False
62
+ seen.add(config)
63
+ try:
64
+ lines = config.read_text(encoding="utf-8").splitlines()
65
+ except (OSError, UnicodeError):
66
+ return False
67
+ for line in lines:
68
+ for token in _parse_include_tokens(line):
69
+ expanded = os.path.expanduser(token)
70
+ candidate = Path(expanded)
71
+ if not candidate.is_absolute():
72
+ candidate = config.parent / candidate
73
+ try:
74
+ if candidate.resolve() == target:
75
+ return True
76
+ except OSError:
77
+ pass
78
+ for include in _expand_include(token, config.parent):
79
+ if include == target or includes_target(include, target, seen):
80
+ return True
81
+ return False
82
+
83
+
84
+ def _render_server(server: dict, credential: dict | None) -> list[str]:
85
+ lines = [
86
+ f"Host {server['alias']}",
87
+ f" HostName {quote_value(server['hostname'])}",
88
+ f" Port {server['port']}",
89
+ f" User {quote_value(server['username'])}",
90
+ ]
91
+ jumps = server["proxy_jumps"]
92
+ lines.append(f" ProxyJump {','.join(jumps) if jumps else 'none'}")
93
+ if credential and credential["kind"] == "password":
94
+ lines.extend(
95
+ [
96
+ " IdentityFile none",
97
+ " IdentitiesOnly yes",
98
+ " PubkeyAuthentication no",
99
+ " PasswordAuthentication yes",
100
+ " KbdInteractiveAuthentication yes",
101
+ " PreferredAuthentications keyboard-interactive,password",
102
+ ]
103
+ )
104
+ elif credential and credential["kind"] == "key":
105
+ lines.extend(
106
+ [
107
+ f" IdentityFile {quote_value(credential['key_path'])}",
108
+ " IdentitiesOnly yes",
109
+ " PubkeyAuthentication yes",
110
+ " PreferredAuthentications publickey",
111
+ ]
112
+ )
113
+ lines.append("")
114
+ return lines
115
+
116
+
117
+ def render_config(
118
+ database: Database,
119
+ *,
120
+ destination: str | Path | None = None,
121
+ original: str | Path | None = None,
122
+ ) -> Path:
123
+ destination_path = Path(destination) if destination else managed_ssh_config_path()
124
+ original_path = Path(original) if original else original_ssh_config_path()
125
+ destination_path = destination_path.expanduser().resolve()
126
+ original_path = original_path.expanduser().resolve()
127
+ if includes_target(original_path, destination_path):
128
+ raise SSHConfigError(
129
+ f"refusing to create an Include cycle: {original_path} already includes {destination_path}"
130
+ )
131
+ lines = [
132
+ "# Generated by ssh-server-manager. Do not edit this file directly.",
133
+ "# Edit hosts with serverctl or the local UI.",
134
+ "",
135
+ ]
136
+ credentials = {item["id"]: item for item in database.list_credentials()}
137
+ for server in database.list_servers():
138
+ lines.extend(_render_server(server, credentials.get(server["credential_id"])))
139
+ if original_path.exists() and original_path != destination_path:
140
+ lines.extend([f"Include {quote_value(str(original_path))}", ""])
141
+ content = "\n".join(lines)
142
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
143
+ if os.name != "nt":
144
+ destination_path.parent.chmod(stat.S_IRWXU)
145
+ file_descriptor, temporary = tempfile.mkstemp(prefix=".ssh-server-manager-", dir=destination_path.parent)
146
+ temporary_path = Path(temporary)
147
+ try:
148
+ with os.fdopen(file_descriptor, "w", encoding="utf-8", newline="\n") as handle:
149
+ handle.write(content)
150
+ handle.flush()
151
+ os.fsync(handle.fileno())
152
+ if os.name != "nt":
153
+ temporary_path.chmod(stat.S_IRUSR | stat.S_IWUSR)
154
+ os.replace(temporary_path, destination_path)
155
+ finally:
156
+ temporary_path.unlink(missing_ok=True)
157
+ return destination_path
158
+
159
+
160
+ def effective_config_args(database: Database) -> list[str]:
161
+ return ["-F", str(render_config(database))]
162
+
@@ -0,0 +1,262 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shlex
7
+ import shutil
8
+ import stat
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any, Sequence
14
+
15
+ from .db import Database, NotFoundError
16
+ from .paths import runtime_dir
17
+ from .ssh_config import render_config
18
+
19
+
20
+ class SSHError(RuntimeError):
21
+ pass
22
+
23
+
24
+ PROMPT_REDACTIONS = [
25
+ (re.compile(r"(?i)(password|passphrase)(\s*[:=]\s*)\S+"), r"\1\2[REDACTED]"),
26
+ (re.compile(r"(?i)(token|secret|cookie)(\s*[:=]\s*)\S+"), r"\1\2[REDACTED]"),
27
+ ]
28
+
29
+
30
+ def redact(text: str) -> str:
31
+ result = text
32
+ for pattern, replacement in PROMPT_REDACTIONS:
33
+ result = pattern.sub(replacement, result)
34
+ return result[:4000]
35
+
36
+
37
+ def classify_error(stderr: str, returncode: int) -> str:
38
+ lowered = stderr.casefold()
39
+ if "host key verification failed" in lowered or "no host key is known" in lowered:
40
+ return "host-key-untrusted"
41
+ if "permission denied" in lowered or "authentication failed" in lowered:
42
+ return "authentication-failed"
43
+ if "connection timed out" in lowered or "operation timed out" in lowered:
44
+ return "timeout"
45
+ if "connection refused" in lowered:
46
+ return "connection-refused"
47
+ if "could not resolve hostname" in lowered or "name or service not known" in lowered:
48
+ return "dns-failed"
49
+ if "no route to host" in lowered or "network is unreachable" in lowered:
50
+ return "network-unreachable"
51
+ if returncode == 127:
52
+ return "ssh-not-found"
53
+ return "ssh-failed"
54
+
55
+
56
+ class SSHRunner:
57
+ def __init__(self, database: Database, *, ssh_binary: str | None = None) -> None:
58
+ self.database = database
59
+ self.ssh_binary = ssh_binary or shutil.which("ssh") or "ssh"
60
+
61
+ def _credential_descriptor(self, server: dict[str, Any]) -> dict[str, Any] | None:
62
+ if not server.get("credential_id"):
63
+ return None
64
+ credential = self.database.get_credential(server["credential_id"])
65
+ if credential["kind"] == "password":
66
+ return {
67
+ "credential_id": credential["id"],
68
+ "slot": "password",
69
+ "alias": server["alias"],
70
+ "hostname": server["hostname"],
71
+ "username": server["username"],
72
+ }
73
+ if credential["kind"] == "key" and credential["has_passphrase"]:
74
+ return {
75
+ "credential_id": credential["id"],
76
+ "slot": "passphrase",
77
+ "alias": server["alias"],
78
+ "hostname": server["hostname"],
79
+ "username": server["username"],
80
+ "key_path": credential["key_path"],
81
+ }
82
+ return None
83
+
84
+ def _auth_descriptors(self, server: dict[str, Any]) -> list[dict[str, Any]]:
85
+ descriptors: list[dict[str, Any]] = []
86
+ visited: set[str] = set()
87
+
88
+ def add(item: dict[str, Any]) -> None:
89
+ key = item["alias"].casefold()
90
+ if key in visited:
91
+ return
92
+ visited.add(key)
93
+ for jump in item["proxy_jumps"]:
94
+ try:
95
+ add(self.database.get_server(jump))
96
+ except NotFoundError:
97
+ continue
98
+ descriptor = self._credential_descriptor(item)
99
+ if descriptor:
100
+ descriptors.append(descriptor)
101
+
102
+ add(server)
103
+ return descriptors
104
+
105
+ def _askpass_launcher(self) -> Path:
106
+ directory = runtime_dir()
107
+ if os.name == "nt":
108
+ launcher = Path(sys.executable).parent / "ssh-server-manager-askpass.exe"
109
+ if not launcher.exists():
110
+ raise SSHError("Windows AskPass launcher is missing; run scripts\\bootstrap.cmd")
111
+ return launcher
112
+ else:
113
+ # Invoke the packaged module so the launcher works both from a
114
+ # source checkout and from a pipx/uv tool install. The package
115
+ # parent goes on PYTHONPATH because ssh launches this in a fresh
116
+ # process that has no knowledge of how serverctl was started.
117
+ package_parent = Path(__file__).resolve().parents[1]
118
+ launcher = directory / "askpass"
119
+ content = (
120
+ "#!/bin/sh\n"
121
+ f'PYTHONPATH={shlex.quote(str(package_parent))}${{PYTHONPATH:+:$PYTHONPATH}}\n'
122
+ "export PYTHONPATH\n"
123
+ f'exec {shlex.quote(sys.executable)} -m ssh_server_manager.askpass "$@"\n'
124
+ )
125
+ if not launcher.exists() or launcher.read_text(encoding="utf-8") != content:
126
+ launcher.write_text(content, encoding="utf-8", newline="\n")
127
+ if os.name != "nt":
128
+ launcher.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
129
+ return launcher
130
+
131
+ def _environment(self, server: dict[str, Any]) -> dict[str, str]:
132
+ environment = os.environ.copy()
133
+ descriptors = self._auth_descriptors(server)
134
+ if descriptors:
135
+ environment["SSH_ASKPASS"] = str(self._askpass_launcher())
136
+ environment["SSH_ASKPASS_REQUIRE"] = "force"
137
+ environment.setdefault("DISPLAY", "ssh-server-manager:0")
138
+ environment["SSM_ASKPASS_MAP"] = json.dumps(descriptors, separators=(",", ":"))
139
+ return environment
140
+
141
+ def _base_command(self, server: dict[str, Any], *, timeout: int, reuse: int = 0) -> list[str]:
142
+ config = render_config(self.database)
143
+ command = [
144
+ self.ssh_binary,
145
+ "-F",
146
+ str(config),
147
+ "-o",
148
+ f"ConnectTimeout={timeout}",
149
+ "-o",
150
+ "ConnectionAttempts=1",
151
+ "-o",
152
+ "GSSAPIAuthentication=no",
153
+ "-o",
154
+ "ServerAliveInterval=15",
155
+ "-o",
156
+ "ServerAliveCountMax=2",
157
+ "-o",
158
+ "LogLevel=ERROR",
159
+ ]
160
+ if reuse > 0 and os.name != "nt":
161
+ command.extend(
162
+ [
163
+ "-o",
164
+ "ControlMaster=auto",
165
+ "-o",
166
+ f"ControlPersist={reuse}",
167
+ "-o",
168
+ f"ControlPath={runtime_dir() / 'cm-%C'}",
169
+ ]
170
+ )
171
+ command.append(server["alias"])
172
+ return command
173
+
174
+ def test(self, identifier: str, *, timeout: int = 8) -> dict[str, Any]:
175
+ server = self.database.get_server(identifier)
176
+ command = self._base_command(server, timeout=timeout)
177
+ command[-1:-1] = ["-T", "-o", "StrictHostKeyChecking=yes", "-o", "NumberOfPasswordPrompts=1"]
178
+ command.append("true")
179
+ start = time.monotonic()
180
+ try:
181
+ result = subprocess.run(
182
+ command,
183
+ text=True,
184
+ capture_output=True,
185
+ env=self._environment(server),
186
+ timeout=timeout + 5,
187
+ check=False,
188
+ )
189
+ latency = round((time.monotonic() - start) * 1000)
190
+ success = result.returncode == 0
191
+ error_code = None if success else classify_error(result.stderr, result.returncode)
192
+ message = "connection succeeded" if success else redact(result.stderr.strip() or "SSH returned no diagnostics")
193
+ except subprocess.TimeoutExpired:
194
+ latency = round((time.monotonic() - start) * 1000)
195
+ success, error_code, message = False, "timeout", f"connection exceeded {timeout + 5} seconds"
196
+ except OSError as exc:
197
+ latency = round((time.monotonic() - start) * 1000)
198
+ success, error_code, message = False, "ssh-not-found", str(exc)
199
+ self.database.record_test(
200
+ server["id"],
201
+ status="ok" if success else "failed",
202
+ latency_ms=latency,
203
+ error_code=error_code,
204
+ message=message,
205
+ )
206
+ return {
207
+ "alias": server["alias"],
208
+ "ok": success,
209
+ "latency_ms": latency,
210
+ "error_code": error_code,
211
+ "message": message,
212
+ }
213
+
214
+ def connect(self, identifier: str, *, timeout: int = 12) -> int:
215
+ server = self.database.get_server(identifier)
216
+ command = self._base_command(server, timeout=timeout)
217
+ return subprocess.call(command, env=self._environment(server))
218
+
219
+ def execute(
220
+ self,
221
+ identifier: str,
222
+ remote_args: Sequence[str],
223
+ *,
224
+ stdin_data: str | bytes | None = None,
225
+ timeout: int = 30,
226
+ reuse: int = 0,
227
+ capture: bool = False,
228
+ ) -> dict[str, Any]:
229
+ if not remote_args:
230
+ raise SSHError("a remote command is required after --")
231
+ server = self.database.get_server(identifier)
232
+ command = self._base_command(server, timeout=timeout, reuse=reuse)
233
+ command[-1:-1] = ["-T", "-o", "StrictHostKeyChecking=yes", "-o", "NumberOfPasswordPrompts=1"]
234
+ command.append(shlex.join(list(remote_args)))
235
+ start = time.monotonic()
236
+ binary_input = isinstance(stdin_data, bytes)
237
+ result = subprocess.run(
238
+ command,
239
+ input=stdin_data,
240
+ text=not binary_input,
241
+ capture_output=capture,
242
+ env=self._environment(server),
243
+ check=False,
244
+ )
245
+ output = {
246
+ "alias": server["alias"],
247
+ "ok": result.returncode == 0,
248
+ "returncode": result.returncode,
249
+ "latency_ms": round((time.monotonic() - start) * 1000),
250
+ }
251
+ if capture:
252
+ stdout = result.stdout
253
+ stderr = result.stderr
254
+ if isinstance(stdout, bytes):
255
+ stdout = stdout.decode("utf-8", errors="replace")
256
+ if isinstance(stderr, bytes):
257
+ stderr = stderr.decode("utf-8", errors="replace")
258
+ output["stdout"] = stdout
259
+ output["stderr"] = redact(stderr)
260
+ if result.returncode:
261
+ output["error_code"] = classify_error(stderr, result.returncode)
262
+ return output
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+
9
+ ALIAS_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$")
10
+ CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
11
+ KINDS = {"password", "key", "agent"}
12
+
13
+
14
+ class ValidationError(ValueError):
15
+ pass
16
+
17
+
18
+ def validate_alias(value: str) -> str:
19
+ value = value.strip()
20
+ if not ALIAS_RE.fullmatch(value):
21
+ raise ValidationError("alias must be 1-63 characters using letters, digits, '.', '_' or '-'")
22
+ return value
23
+
24
+
25
+ def validate_hostname(value: str) -> str:
26
+ value = value.strip()
27
+ if not value or len(value) > 253 or CONTROL_RE.search(value) or any(c.isspace() for c in value):
28
+ raise ValidationError("hostname must be a non-empty DNS name or IP address without whitespace")
29
+ try:
30
+ ipaddress.ip_address(value.strip("[]"))
31
+ except ValueError:
32
+ labels = value.rstrip(".").split(".")
33
+ if any(not label or len(label) > 63 for label in labels):
34
+ raise ValidationError("invalid hostname")
35
+ return value
36
+
37
+
38
+ def validate_port(value: int | str) -> int:
39
+ try:
40
+ port = int(value)
41
+ except (TypeError, ValueError) as exc:
42
+ raise ValidationError("port must be an integer") from exc
43
+ if not 1 <= port <= 65535:
44
+ raise ValidationError("port must be between 1 and 65535")
45
+ return port
46
+
47
+
48
+ def validate_username(value: str) -> str:
49
+ value = value.strip()
50
+ if not value or len(value) > 128 or CONTROL_RE.search(value) or any(c.isspace() for c in value):
51
+ raise ValidationError("username must not be empty or contain whitespace")
52
+ return value
53
+
54
+
55
+ def validate_label(value: str) -> str:
56
+ value = value.strip()
57
+ if not value or len(value) > 100 or CONTROL_RE.search(value):
58
+ raise ValidationError("credential label must be 1-100 printable characters")
59
+ return value
60
+
61
+
62
+ def validate_kind(value: str) -> str:
63
+ if value not in KINDS:
64
+ raise ValidationError(f"credential kind must be one of {', '.join(sorted(KINDS))}")
65
+ return value
66
+
67
+
68
+ def validate_key_path(value: str) -> str:
69
+ path = Path(value).expanduser()
70
+ if not path.is_absolute():
71
+ path = path.resolve()
72
+ if not path.exists() or not path.is_file():
73
+ raise ValidationError(f"private key does not exist: {path}")
74
+ return str(path)
75
+
76
+
77
+ def validate_proxy_jumps(alias: str, jumps: Iterable[str]) -> list[str]:
78
+ normalized: list[str] = []
79
+ seen: set[str] = set()
80
+ for jump in jumps:
81
+ item = validate_alias(jump)
82
+ key = item.casefold()
83
+ if key == alias.casefold():
84
+ raise ValidationError("a server cannot ProxyJump through itself")
85
+ if key not in seen:
86
+ normalized.append(item)
87
+ seen.add(key)
88
+ return normalized
89
+