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,406 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import getpass
5
+ import json
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any, Sequence
13
+
14
+ from . import __version__
15
+ from .db import ConflictError, Database, DatabaseError, NotFoundError
16
+ from .importer import apply_import, preview_import
17
+ from .paths import database_path, managed_ssh_config_path, original_ssh_config_path
18
+ from .service import CredentialService
19
+ from .ssh_config import SSHConfigError, render_config
20
+ from .ssh_runner import SSHError, SSHRunner
21
+ from .validation import ValidationError
22
+ from .vault import VaultError, get_vault
23
+
24
+
25
+ def emit(value: Any, *, as_json: bool = False) -> None:
26
+ if as_json:
27
+ print(json.dumps(value, indent=2, ensure_ascii=False, default=str))
28
+ return
29
+ if isinstance(value, list):
30
+ for item in value:
31
+ print(format_item(item))
32
+ elif isinstance(value, dict):
33
+ for key, item in value.items():
34
+ if isinstance(item, (dict, list)):
35
+ print(f"{key}={json.dumps(item, ensure_ascii=False)}")
36
+ else:
37
+ print(f"{key}={item}")
38
+ else:
39
+ print(value)
40
+
41
+
42
+ def format_item(item: Any) -> str:
43
+ if not isinstance(item, dict):
44
+ return str(item)
45
+ if "hostname" in item:
46
+ credential = item.get("credential_label") or "OpenSSH default"
47
+ jumps = ",".join(item.get("proxy_jumps", [])) or "-"
48
+ return f"{item['alias']:<22} {item['username']}@{item['hostname']}:{item['port']} auth={credential} jump={jumps}"
49
+ if "kind" in item:
50
+ detail = item.get("key_path") or ("stored" if item.get("has_secret") else "not stored")
51
+ return f"{item['label']:<22} {item['kind']:<8} {detail}"
52
+ return json.dumps(item, ensure_ascii=False)
53
+
54
+
55
+ def add_json_option(parser: argparse.ArgumentParser) -> None:
56
+ parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
57
+
58
+
59
+ def build_parser() -> argparse.ArgumentParser:
60
+ parser = argparse.ArgumentParser(prog="serverctl", description="Manage SSH hosts and secure credentials")
61
+ parser.add_argument("--version", action="version", version=f"serverctl {__version__}")
62
+ commands = parser.add_subparsers(dest="command", required=True)
63
+
64
+ doctor = commands.add_parser("doctor", help="diagnose local dependencies and storage")
65
+ add_json_option(doctor)
66
+
67
+ ui = commands.add_parser("ui", help="open the local management UI")
68
+ ui.add_argument("--no-open", action="store_true", help="do not open a browser")
69
+ ui.add_argument(
70
+ "--port",
71
+ type=int,
72
+ default=0,
73
+ help="loopback port; 0 (the default) chooses an available port",
74
+ )
75
+ ui.add_argument(
76
+ "--url-file",
77
+ type=Path,
78
+ help="write the one-time URL to a mode-600 local file (required with --no-open)",
79
+ )
80
+
81
+ server = commands.add_parser("server", help="manage connection profiles")
82
+ server_commands = server.add_subparsers(dest="server_command", required=True)
83
+ server_list = server_commands.add_parser("list")
84
+ add_json_option(server_list)
85
+ server_show = server_commands.add_parser("show")
86
+ server_show.add_argument("alias")
87
+ add_json_option(server_show)
88
+
89
+ server_add = server_commands.add_parser("add")
90
+ server_add.add_argument("alias")
91
+ server_add.add_argument("--hostname", required=True)
92
+ server_add.add_argument("--username", required=True)
93
+ server_add.add_argument("--port", type=int, default=22)
94
+ server_add.add_argument("--credential")
95
+ server_add.add_argument("--proxy-jump", action="append", default=[])
96
+ server_add.add_argument("--notes", default="")
97
+ add_json_option(server_add)
98
+
99
+ server_edit = server_commands.add_parser("edit")
100
+ server_edit.add_argument("alias")
101
+ server_edit.add_argument("--new-alias")
102
+ server_edit.add_argument("--hostname")
103
+ server_edit.add_argument("--username")
104
+ server_edit.add_argument("--port", type=int)
105
+ server_edit.add_argument("--credential", help="credential label/id, or 'none'")
106
+ server_edit.add_argument("--proxy-jump", action="append")
107
+ server_edit.add_argument("--notes")
108
+ add_json_option(server_edit)
109
+
110
+ server_remove = server_commands.add_parser("remove")
111
+ server_remove.add_argument("alias")
112
+ server_remove.add_argument("--yes", action="store_true")
113
+ add_json_option(server_remove)
114
+
115
+ server_import = server_commands.add_parser("import")
116
+ server_import.add_argument("--config")
117
+ server_import.add_argument("--apply", action="store_true")
118
+ server_import.add_argument("--overwrite", action="store_true")
119
+ add_json_option(server_import)
120
+
121
+ server_test = server_commands.add_parser("test")
122
+ server_test.add_argument("alias")
123
+ server_test.add_argument("--timeout", type=int, default=8)
124
+ add_json_option(server_test)
125
+
126
+ credential = commands.add_parser("credential", help="manage reusable credentials")
127
+ credential_commands = credential.add_subparsers(dest="credential_command", required=True)
128
+ credential_list = credential_commands.add_parser("list")
129
+ add_json_option(credential_list)
130
+ add_password = credential_commands.add_parser("add-password")
131
+ add_password.add_argument("label")
132
+ add_json_option(add_password)
133
+ add_key = credential_commands.add_parser("add-key")
134
+ add_key.add_argument("label")
135
+ add_key.add_argument("--key-path", required=True)
136
+ add_key.add_argument("--store-passphrase", action="store_true")
137
+ add_json_option(add_key)
138
+ add_agent = credential_commands.add_parser("add-agent")
139
+ add_agent.add_argument("label")
140
+ add_json_option(add_agent)
141
+ edit_credential = credential_commands.add_parser("edit")
142
+ edit_credential.add_argument("credential")
143
+ edit_credential.add_argument("--label")
144
+ edit_credential.add_argument("--key-path")
145
+ edit_credential.add_argument("--replace-secret", action="store_true")
146
+ edit_credential.add_argument("--replace-passphrase", action="store_true")
147
+ edit_credential.add_argument("--clear-passphrase", action="store_true")
148
+ add_json_option(edit_credential)
149
+ remove_credential = credential_commands.add_parser("remove")
150
+ remove_credential.add_argument("credential")
151
+ remove_credential.add_argument("--yes", action="store_true")
152
+ add_json_option(remove_credential)
153
+
154
+ connect = commands.add_parser("connect", help="open an SSH session")
155
+ connect.add_argument("alias")
156
+ connect.add_argument("--timeout", type=int, default=12)
157
+
158
+ execute = commands.add_parser("exec", help="execute a remote command")
159
+ execute.add_argument("alias")
160
+ execute.add_argument("--stdin", action="store_true", help="read UTF-8 text from stdin")
161
+ execute.add_argument(
162
+ "--stdin-binary",
163
+ action="store_true",
164
+ help="read raw bytes from stdin (implies --stdin)",
165
+ )
166
+ execute.add_argument("--timeout", type=int, default=30)
167
+ execute.add_argument("--reuse", type=int, default=0)
168
+ execute.add_argument(
169
+ "--shell",
170
+ action="store_true",
171
+ help="run one command string through the remote POSIX sh -lc",
172
+ )
173
+ execute.add_argument("--json", action="store_true")
174
+
175
+ config = commands.add_parser("config", help="render managed OpenSSH config")
176
+ config_commands = config.add_subparsers(dest="config_command", required=True)
177
+ config_render = config_commands.add_parser("render")
178
+ add_json_option(config_render)
179
+ return parser
180
+
181
+
182
+ def resolve_credential(database: Database, value: str | None) -> str | None:
183
+ if not value or value.casefold() == "none":
184
+ return None
185
+ return database.get_credential(value)["id"]
186
+
187
+
188
+ def confirm(prompt: str, forced: bool) -> bool:
189
+ if forced:
190
+ return True
191
+ if not sys.stdin.isatty():
192
+ return False
193
+ return input(f"{prompt} [y/N] ").strip().casefold() in {"y", "yes"}
194
+
195
+
196
+ def normalize_remote_command(remote: Sequence[str], *, shell: bool = False) -> list[str]:
197
+ """Normalize the command after ``--`` without guessing at shell syntax."""
198
+ command = list(remote)
199
+ if not command:
200
+ raise ValidationError("a remote command is required after --")
201
+ if not shell:
202
+ return command
203
+ if len(command) != 1:
204
+ raise ValidationError("--shell expects exactly one command string after --")
205
+ return ["sh", "-lc", command[0]]
206
+
207
+
208
+ def ssh_client_version(ssh_path: str | None) -> str | None:
209
+ if not ssh_path:
210
+ return None
211
+ try:
212
+ result = subprocess.run([ssh_path, "-V"], capture_output=True, text=True, timeout=5, check=False)
213
+ except (OSError, subprocess.TimeoutExpired):
214
+ return None
215
+ return (result.stderr or result.stdout).strip().splitlines()[0] if (result.stderr or result.stdout).strip() else None
216
+
217
+
218
+ def run_doctor(database: Database) -> dict[str, Any]:
219
+ ssh_path = shutil.which("ssh")
220
+ checks: dict[str, Any] = {
221
+ "version": {"ok": True, "serverctl": __version__},
222
+ "platform": {
223
+ "ok": True,
224
+ "system": platform.system(),
225
+ "release": platform.release(),
226
+ "python": platform.python_version(),
227
+ "connection_reuse": os.name != "nt",
228
+ },
229
+ "database": {"ok": True, "path": str(database_path())},
230
+ "ssh": {"ok": bool(ssh_path), "path": ssh_path, "client": ssh_client_version(ssh_path)},
231
+ "ssh_keygen": {"ok": bool(shutil.which("ssh-keygen")), "path": shutil.which("ssh-keygen")},
232
+ "original_config": {"ok": True, "path": str(original_ssh_config_path())},
233
+ "managed_config": {"ok": True, "path": str(managed_ssh_config_path())},
234
+ }
235
+ try:
236
+ checks["vault"] = {"ok": True, **get_vault().diagnose()}
237
+ except VaultError as exc:
238
+ checks["vault"] = {"ok": False, "message": str(exc)}
239
+ for module in ("fastapi", "uvicorn", "webauthn", "argon2"):
240
+ try:
241
+ __import__(module)
242
+ checks[module] = {"ok": True}
243
+ except ImportError:
244
+ checks[module] = {"ok": False, "message": "run scripts/bootstrap"}
245
+ checks["ok"] = all(item.get("ok", False) for item in checks.values() if isinstance(item, dict))
246
+ return checks
247
+
248
+
249
+ def handle(args: argparse.Namespace) -> int:
250
+ database = Database()
251
+ if args.command == "doctor":
252
+ result = run_doctor(database)
253
+ emit(result, as_json=args.json)
254
+ return 0 if result["ok"] else 1
255
+ if args.command == "ui":
256
+ from .webapp import run_ui
257
+
258
+ run_ui(
259
+ database=database,
260
+ port=args.port,
261
+ open_browser=not args.no_open,
262
+ url_file=args.url_file,
263
+ )
264
+ return 0
265
+ if args.command == "server":
266
+ if args.server_command == "list":
267
+ emit(database.list_servers(), as_json=args.json)
268
+ elif args.server_command == "show":
269
+ emit(database.get_server(args.alias), as_json=args.json)
270
+ elif args.server_command == "add":
271
+ result = database.create_server(
272
+ alias=args.alias,
273
+ hostname=args.hostname,
274
+ port=args.port,
275
+ username=args.username,
276
+ credential_id=resolve_credential(database, args.credential),
277
+ proxy_jumps=args.proxy_jump,
278
+ notes=args.notes,
279
+ )
280
+ render_config(database)
281
+ emit(result, as_json=args.json)
282
+ elif args.server_command == "edit":
283
+ changes = {
284
+ key: value
285
+ for key, value in {
286
+ "alias": args.new_alias,
287
+ "hostname": args.hostname,
288
+ "port": args.port,
289
+ "username": args.username,
290
+ "notes": args.notes,
291
+ }.items()
292
+ if value is not None
293
+ }
294
+ if args.credential is not None:
295
+ changes["credential_id"] = resolve_credential(database, args.credential)
296
+ if args.proxy_jump is not None:
297
+ changes["proxy_jumps"] = args.proxy_jump
298
+ result = database.update_server(args.alias, **changes)
299
+ render_config(database)
300
+ emit(result, as_json=args.json)
301
+ elif args.server_command == "remove":
302
+ if not confirm(f"Remove server {args.alias}?", args.yes):
303
+ raise DatabaseError("removal cancelled; use --yes for non-interactive removal")
304
+ result = database.delete_server(args.alias)
305
+ render_config(database)
306
+ emit(result, as_json=args.json)
307
+ elif args.server_command == "import":
308
+ preview = preview_import(database, config=args.config)
309
+ result = apply_import(database, preview, overwrite=args.overwrite) if args.apply else preview
310
+ if args.apply:
311
+ render_config(database)
312
+ emit(result, as_json=args.json)
313
+ elif args.server_command == "test":
314
+ result = SSHRunner(database).test(args.alias, timeout=args.timeout)
315
+ emit(result, as_json=args.json)
316
+ return 0 if result["ok"] else 1
317
+ return 0
318
+ if args.command == "credential":
319
+ if args.credential_command == "list":
320
+ emit(database.list_credentials(), as_json=args.json)
321
+ return 0
322
+ service = CredentialService(database, get_vault())
323
+ if args.credential_command == "add-password":
324
+ first = getpass.getpass("Password: ")
325
+ second = getpass.getpass("Confirm password: ")
326
+ if first != second:
327
+ raise VaultError("password confirmation does not match")
328
+ result = service.create_password(args.label, first)
329
+ elif args.credential_command == "add-key":
330
+ passphrase = getpass.getpass("Private-key passphrase: ") if args.store_passphrase else None
331
+ result = service.create_key(args.label, args.key_path, passphrase or None)
332
+ elif args.credential_command == "add-agent":
333
+ result = service.create_agent(args.label)
334
+ elif args.credential_command == "edit":
335
+ secret = getpass.getpass("New password: ") if args.replace_secret else None
336
+ passphrase = getpass.getpass("New private-key passphrase: ") if args.replace_passphrase else None
337
+ result = service.update(
338
+ args.credential,
339
+ label=args.label,
340
+ key_path=args.key_path,
341
+ secret=secret,
342
+ passphrase=passphrase,
343
+ clear_passphrase=args.clear_passphrase,
344
+ )
345
+ elif args.credential_command == "remove":
346
+ if not confirm(f"Remove credential {args.credential}?", args.yes):
347
+ raise DatabaseError("removal cancelled; use --yes for non-interactive removal")
348
+ result = service.delete(args.credential)
349
+ render_config(database)
350
+ emit(result, as_json=args.json)
351
+ return 0
352
+ if args.command == "connect":
353
+ return SSHRunner(database).connect(args.alias, timeout=args.timeout)
354
+ if args.command == "exec":
355
+ if args.reuse > 0 and os.name == "nt":
356
+ print(
357
+ "warning: --reuse is ignored on Windows; OpenSSH ControlMaster needs Unix domain sockets",
358
+ file=sys.stderr,
359
+ )
360
+ remote = normalize_remote_command(args.remote_command, shell=args.shell)
361
+ if args.stdin_binary:
362
+ stdin_data = sys.stdin.buffer.read()
363
+ else:
364
+ stdin_data = sys.stdin.read() if args.stdin else None
365
+ result = SSHRunner(database).execute(
366
+ args.alias,
367
+ remote,
368
+ stdin_data=stdin_data,
369
+ timeout=args.timeout,
370
+ reuse=args.reuse,
371
+ capture=args.json,
372
+ )
373
+ if args.json:
374
+ emit(result, as_json=True)
375
+ return int(result["returncode"])
376
+ if args.command == "config" and args.config_command == "render":
377
+ path = render_config(database)
378
+ emit({"path": str(path)}, as_json=args.json)
379
+ return 0
380
+ return 2
381
+
382
+
383
+ def main(argv: Sequence[str] | None = None) -> int:
384
+ parser = build_parser()
385
+ raw_argv = list(argv) if argv is not None else sys.argv[1:]
386
+ remote_command: list[str] = []
387
+ if raw_argv and raw_argv[0] == "exec" and "--" in raw_argv:
388
+ separator = raw_argv.index("--")
389
+ remote_command = raw_argv[separator + 1 :]
390
+ raw_argv = raw_argv[:separator]
391
+ args = parser.parse_args(raw_argv)
392
+ if args.command == "exec":
393
+ args.remote_command = remote_command
394
+ try:
395
+ return handle(args)
396
+ except (ValidationError, VaultError, DatabaseError, SSHConfigError, SSHError, ValueError) as exc:
397
+ as_json = bool(getattr(args, "json", False))
398
+ if as_json:
399
+ print(json.dumps({"ok": False, "error": exc.__class__.__name__, "message": str(exc)}, ensure_ascii=False))
400
+ else:
401
+ print(f"error: {exc}", file=sys.stderr)
402
+ return 2
403
+
404
+
405
+ if __name__ == "__main__":
406
+ raise SystemExit(main())