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.
- ssh_server_manager/__init__.py +4 -0
- ssh_server_manager/askpass.py +63 -0
- ssh_server_manager/assets/ui/app.js +452 -0
- ssh_server_manager/assets/ui/index.html +188 -0
- ssh_server_manager/assets/ui/styles.css +104 -0
- ssh_server_manager/auth.py +189 -0
- ssh_server_manager/cli.py +406 -0
- ssh_server_manager/db.py +477 -0
- ssh_server_manager/importer.py +149 -0
- ssh_server_manager/paths.py +80 -0
- ssh_server_manager/service.py +102 -0
- ssh_server_manager/ssh_config.py +162 -0
- ssh_server_manager/ssh_runner.py +262 -0
- ssh_server_manager/validation.py +89 -0
- ssh_server_manager/vault.py +113 -0
- ssh_server_manager/webapp.py +377 -0
- ssh_server_manager-0.2.0.dist-info/METADATA +173 -0
- ssh_server_manager-0.2.0.dist-info/RECORD +21 -0
- ssh_server_manager-0.2.0.dist-info/WHEEL +5 -0
- ssh_server_manager-0.2.0.dist-info/entry_points.txt +3 -0
- ssh_server_manager-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|