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,477 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ import stat
7
+ import uuid
8
+ from contextlib import contextmanager
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any, Iterator
12
+
13
+ from .paths import database_path, ensure_private_dir
14
+ from .validation import (
15
+ ValidationError,
16
+ validate_alias,
17
+ validate_hostname,
18
+ validate_key_path,
19
+ validate_kind,
20
+ validate_label,
21
+ validate_port,
22
+ validate_proxy_jumps,
23
+ validate_username,
24
+ )
25
+
26
+
27
+ SCHEMA_VERSION = 1
28
+
29
+
30
+ def utc_now() -> str:
31
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
32
+
33
+
34
+ class DatabaseError(RuntimeError):
35
+ pass
36
+
37
+
38
+ class NotFoundError(DatabaseError):
39
+ pass
40
+
41
+
42
+ class ConflictError(DatabaseError):
43
+ pass
44
+
45
+
46
+ class Database:
47
+ def __init__(self, path: str | Path | None = None) -> None:
48
+ self.path = Path(path) if path else database_path()
49
+ ensure_private_dir(self.path.parent)
50
+ self._initialize()
51
+
52
+ def connect(self) -> sqlite3.Connection:
53
+ connection = sqlite3.connect(self.path, timeout=10)
54
+ connection.row_factory = sqlite3.Row
55
+ connection.execute("PRAGMA foreign_keys = ON")
56
+ connection.execute("PRAGMA busy_timeout = 10000")
57
+ return connection
58
+
59
+ @contextmanager
60
+ def transaction(self) -> Iterator[sqlite3.Connection]:
61
+ connection = self.connect()
62
+ try:
63
+ connection.execute("BEGIN IMMEDIATE")
64
+ yield connection
65
+ connection.commit()
66
+ except Exception:
67
+ connection.rollback()
68
+ raise
69
+ finally:
70
+ connection.close()
71
+
72
+ def _initialize(self) -> None:
73
+ is_new = not self.path.exists()
74
+ with self.connect() as connection:
75
+ version = connection.execute("PRAGMA user_version").fetchone()[0]
76
+ if version not in (0, SCHEMA_VERSION):
77
+ raise DatabaseError(f"database schema {version} is newer than supported {SCHEMA_VERSION}")
78
+ if version == SCHEMA_VERSION:
79
+ return
80
+ connection.executescript(
81
+ """
82
+ CREATE TABLE IF NOT EXISTS credentials (
83
+ id TEXT PRIMARY KEY,
84
+ label TEXT NOT NULL COLLATE NOCASE UNIQUE,
85
+ kind TEXT NOT NULL CHECK(kind IN ('password', 'key', 'agent')),
86
+ key_path TEXT,
87
+ has_secret INTEGER NOT NULL DEFAULT 0 CHECK(has_secret IN (0, 1)),
88
+ has_passphrase INTEGER NOT NULL DEFAULT 0 CHECK(has_passphrase IN (0, 1)),
89
+ created_at TEXT NOT NULL,
90
+ updated_at TEXT NOT NULL,
91
+ CHECK((kind = 'key' AND key_path IS NOT NULL) OR (kind != 'key' AND key_path IS NULL))
92
+ );
93
+
94
+ CREATE TABLE IF NOT EXISTS servers (
95
+ id TEXT PRIMARY KEY,
96
+ alias TEXT NOT NULL COLLATE NOCASE UNIQUE,
97
+ hostname TEXT NOT NULL,
98
+ port INTEGER NOT NULL CHECK(port BETWEEN 1 AND 65535),
99
+ username TEXT NOT NULL,
100
+ credential_id TEXT REFERENCES credentials(id) ON DELETE RESTRICT,
101
+ proxy_jumps TEXT NOT NULL DEFAULT '[]',
102
+ notes TEXT NOT NULL DEFAULT '',
103
+ source TEXT NOT NULL DEFAULT 'managed',
104
+ last_test_at TEXT,
105
+ last_test_status TEXT,
106
+ last_test_latency_ms INTEGER,
107
+ last_test_error_code TEXT,
108
+ last_test_message TEXT,
109
+ created_at TEXT NOT NULL,
110
+ updated_at TEXT NOT NULL
111
+ );
112
+
113
+ CREATE TABLE IF NOT EXISTS settings (
114
+ key TEXT PRIMARY KEY,
115
+ value TEXT NOT NULL,
116
+ updated_at TEXT NOT NULL
117
+ );
118
+
119
+ CREATE TABLE IF NOT EXISTS webauthn_credentials (
120
+ credential_id TEXT PRIMARY KEY,
121
+ public_key TEXT NOT NULL,
122
+ sign_count INTEGER NOT NULL DEFAULT 0,
123
+ transports TEXT NOT NULL DEFAULT '[]',
124
+ device_type TEXT,
125
+ backed_up INTEGER NOT NULL DEFAULT 0 CHECK(backed_up IN (0, 1)),
126
+ created_at TEXT NOT NULL,
127
+ updated_at TEXT NOT NULL
128
+ );
129
+
130
+ CREATE INDEX IF NOT EXISTS idx_servers_credential ON servers(credential_id);
131
+ PRAGMA user_version = 1;
132
+ """
133
+ )
134
+ if is_new and os.name != "nt":
135
+ self.path.chmod(stat.S_IRUSR | stat.S_IWUSR)
136
+
137
+ @staticmethod
138
+ def _credential_dict(row: sqlite3.Row) -> dict[str, Any]:
139
+ return {
140
+ "id": row["id"],
141
+ "label": row["label"],
142
+ "kind": row["kind"],
143
+ "key_path": row["key_path"],
144
+ "has_secret": bool(row["has_secret"]),
145
+ "has_passphrase": bool(row["has_passphrase"]),
146
+ "created_at": row["created_at"],
147
+ "updated_at": row["updated_at"],
148
+ }
149
+
150
+ @staticmethod
151
+ def _server_dict(row: sqlite3.Row) -> dict[str, Any]:
152
+ return {
153
+ "id": row["id"],
154
+ "alias": row["alias"],
155
+ "hostname": row["hostname"],
156
+ "port": row["port"],
157
+ "username": row["username"],
158
+ "credential_id": row["credential_id"],
159
+ "credential_label": row["credential_label"] if "credential_label" in row.keys() else None,
160
+ "credential_kind": row["credential_kind"] if "credential_kind" in row.keys() else None,
161
+ "proxy_jumps": json.loads(row["proxy_jumps"] or "[]"),
162
+ "notes": row["notes"],
163
+ "source": row["source"],
164
+ "last_test_at": row["last_test_at"],
165
+ "last_test_status": row["last_test_status"],
166
+ "last_test_latency_ms": row["last_test_latency_ms"],
167
+ "last_test_error_code": row["last_test_error_code"],
168
+ "last_test_message": row["last_test_message"],
169
+ "created_at": row["created_at"],
170
+ "updated_at": row["updated_at"],
171
+ }
172
+
173
+ def list_credentials(self) -> list[dict[str, Any]]:
174
+ with self.connect() as connection:
175
+ rows = connection.execute("SELECT * FROM credentials ORDER BY label COLLATE NOCASE").fetchall()
176
+ return [self._credential_dict(row) for row in rows]
177
+
178
+ def get_credential(self, identifier: str) -> dict[str, Any]:
179
+ with self.connect() as connection:
180
+ row = connection.execute(
181
+ "SELECT * FROM credentials WHERE id = ? OR label = ? COLLATE NOCASE", (identifier, identifier)
182
+ ).fetchone()
183
+ if not row:
184
+ raise NotFoundError(f"credential not found: {identifier}")
185
+ return self._credential_dict(row)
186
+
187
+ def create_credential(
188
+ self,
189
+ *,
190
+ label: str,
191
+ kind: str,
192
+ key_path: str | None = None,
193
+ has_secret: bool = False,
194
+ has_passphrase: bool = False,
195
+ credential_id: str | None = None,
196
+ ) -> dict[str, Any]:
197
+ label = validate_label(label)
198
+ kind = validate_kind(kind)
199
+ if kind == "key":
200
+ if not key_path:
201
+ raise ValidationError("key credentials require --key-path")
202
+ key_path = validate_key_path(key_path)
203
+ else:
204
+ key_path = None
205
+ has_passphrase = False
206
+ if kind != "password":
207
+ has_secret = False
208
+ now = utc_now()
209
+ identifier = credential_id or str(uuid.uuid4())
210
+ try:
211
+ with self.transaction() as connection:
212
+ connection.execute(
213
+ """
214
+ INSERT INTO credentials
215
+ (id, label, kind, key_path, has_secret, has_passphrase, created_at, updated_at)
216
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
217
+ """,
218
+ (identifier, label, kind, key_path, int(has_secret), int(has_passphrase), now, now),
219
+ )
220
+ except sqlite3.IntegrityError as exc:
221
+ raise ConflictError(f"credential label already exists: {label}") from exc
222
+ return self.get_credential(identifier)
223
+
224
+ def update_credential(self, identifier: str, **changes: Any) -> dict[str, Any]:
225
+ current = self.get_credential(identifier)
226
+ label = validate_label(changes.get("label", current["label"]))
227
+ key_path = changes.get("key_path", current["key_path"])
228
+ if current["kind"] == "key":
229
+ key_path = validate_key_path(key_path)
230
+ has_secret = bool(changes.get("has_secret", current["has_secret"]))
231
+ has_passphrase = bool(changes.get("has_passphrase", current["has_passphrase"]))
232
+ try:
233
+ with self.transaction() as connection:
234
+ connection.execute(
235
+ """
236
+ UPDATE credentials SET label = ?, key_path = ?, has_secret = ?, has_passphrase = ?, updated_at = ?
237
+ WHERE id = ?
238
+ """,
239
+ (label, key_path, int(has_secret), int(has_passphrase), utc_now(), current["id"]),
240
+ )
241
+ except sqlite3.IntegrityError as exc:
242
+ raise ConflictError(f"credential label already exists: {label}") from exc
243
+ return self.get_credential(current["id"])
244
+
245
+ def delete_credential(self, identifier: str) -> dict[str, Any]:
246
+ current = self.get_credential(identifier)
247
+ try:
248
+ with self.transaction() as connection:
249
+ connection.execute("DELETE FROM credentials WHERE id = ?", (current["id"],))
250
+ except sqlite3.IntegrityError as exc:
251
+ raise ConflictError("credential is still assigned to one or more servers") from exc
252
+ return current
253
+
254
+ def _server_query(self) -> str:
255
+ return """
256
+ SELECT s.*, c.label AS credential_label, c.kind AS credential_kind
257
+ FROM servers s LEFT JOIN credentials c ON c.id = s.credential_id
258
+ """
259
+
260
+ def list_servers(self) -> list[dict[str, Any]]:
261
+ with self.connect() as connection:
262
+ rows = connection.execute(self._server_query() + " ORDER BY s.alias COLLATE NOCASE").fetchall()
263
+ return [self._server_dict(row) for row in rows]
264
+
265
+ def get_server(self, identifier: str) -> dict[str, Any]:
266
+ with self.connect() as connection:
267
+ row = connection.execute(
268
+ self._server_query() + " WHERE s.id = ? OR s.alias = ? COLLATE NOCASE", (identifier, identifier)
269
+ ).fetchone()
270
+ if not row:
271
+ raise NotFoundError(f"server not found: {identifier}")
272
+ return self._server_dict(row)
273
+
274
+ def _validate_server_payload(self, payload: dict[str, Any], *, current_id: str | None = None) -> dict[str, Any]:
275
+ alias = validate_alias(str(payload["alias"]))
276
+ credential_id = payload.get("credential_id") or None
277
+ if credential_id:
278
+ credential_id = self.get_credential(str(credential_id))["id"]
279
+ normalized = {
280
+ "alias": alias,
281
+ "hostname": validate_hostname(str(payload["hostname"])),
282
+ "port": validate_port(payload.get("port", 22)),
283
+ "username": validate_username(str(payload["username"])),
284
+ "credential_id": credential_id,
285
+ "proxy_jumps": validate_proxy_jumps(alias, payload.get("proxy_jumps", [])),
286
+ "notes": str(payload.get("notes", "")),
287
+ "source": str(payload.get("source", "managed")),
288
+ }
289
+ self._validate_proxy_graph(normalized, current_id=current_id)
290
+ return normalized
291
+
292
+ def _validate_proxy_graph(self, candidate: dict[str, Any], *, current_id: str | None) -> None:
293
+ servers = self.list_servers()
294
+ graph: dict[str, list[str]] = {
295
+ item["alias"].casefold(): [jump.casefold() for jump in item["proxy_jumps"]]
296
+ for item in servers
297
+ if item["id"] != current_id
298
+ }
299
+ graph[candidate["alias"].casefold()] = [jump.casefold() for jump in candidate["proxy_jumps"]]
300
+
301
+ def visit(node: str, active: set[str], complete: set[str]) -> None:
302
+ if node in active:
303
+ raise ValidationError("ProxyJump configuration contains a cycle")
304
+ if node in complete or node not in graph:
305
+ return
306
+ active.add(node)
307
+ for neighbor in graph[node]:
308
+ visit(neighbor, active, complete)
309
+ active.remove(node)
310
+ complete.add(node)
311
+
312
+ complete: set[str] = set()
313
+ for node in graph:
314
+ visit(node, set(), complete)
315
+
316
+ def create_server(self, **payload: Any) -> dict[str, Any]:
317
+ normalized = self._validate_server_payload(payload)
318
+ identifier = str(payload.get("id") or uuid.uuid4())
319
+ now = utc_now()
320
+ try:
321
+ with self.transaction() as connection:
322
+ connection.execute(
323
+ """
324
+ INSERT INTO servers
325
+ (id, alias, hostname, port, username, credential_id, proxy_jumps, notes, source, created_at, updated_at)
326
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
327
+ """,
328
+ (
329
+ identifier,
330
+ normalized["alias"],
331
+ normalized["hostname"],
332
+ normalized["port"],
333
+ normalized["username"],
334
+ normalized["credential_id"],
335
+ json.dumps(normalized["proxy_jumps"]),
336
+ normalized["notes"],
337
+ normalized["source"],
338
+ now,
339
+ now,
340
+ ),
341
+ )
342
+ except sqlite3.IntegrityError as exc:
343
+ raise ConflictError(f"server alias already exists: {normalized['alias']}") from exc
344
+ return self.get_server(identifier)
345
+
346
+ def update_server(self, identifier: str, **changes: Any) -> dict[str, Any]:
347
+ current = self.get_server(identifier)
348
+ payload = {**current, **changes}
349
+ normalized = self._validate_server_payload(payload, current_id=current["id"])
350
+ try:
351
+ with self.transaction() as connection:
352
+ connection.execute(
353
+ """
354
+ UPDATE servers SET alias = ?, hostname = ?, port = ?, username = ?, credential_id = ?,
355
+ proxy_jumps = ?, notes = ?, source = ?, updated_at = ? WHERE id = ?
356
+ """,
357
+ (
358
+ normalized["alias"],
359
+ normalized["hostname"],
360
+ normalized["port"],
361
+ normalized["username"],
362
+ normalized["credential_id"],
363
+ json.dumps(normalized["proxy_jumps"]),
364
+ normalized["notes"],
365
+ normalized["source"],
366
+ utc_now(),
367
+ current["id"],
368
+ ),
369
+ )
370
+ if current["alias"].casefold() != normalized["alias"].casefold():
371
+ dependent_rows = connection.execute("SELECT id, proxy_jumps FROM servers WHERE id != ?", (current["id"],)).fetchall()
372
+ for row in dependent_rows:
373
+ jumps = json.loads(row["proxy_jumps"] or "[]")
374
+ rewritten = [
375
+ normalized["alias"] if jump.casefold() == current["alias"].casefold() else jump
376
+ for jump in jumps
377
+ ]
378
+ if rewritten != jumps:
379
+ connection.execute(
380
+ "UPDATE servers SET proxy_jumps = ?, updated_at = ? WHERE id = ?",
381
+ (json.dumps(rewritten), utc_now(), row["id"]),
382
+ )
383
+ except sqlite3.IntegrityError as exc:
384
+ raise ConflictError(f"server alias already exists: {normalized['alias']}") from exc
385
+ return self.get_server(current["id"])
386
+
387
+ def delete_server(self, identifier: str) -> dict[str, Any]:
388
+ current = self.get_server(identifier)
389
+ target = current["alias"].casefold()
390
+ dependents = [s["alias"] for s in self.list_servers() if target in {j.casefold() for j in s["proxy_jumps"]}]
391
+ if dependents:
392
+ raise ConflictError(f"server is used as ProxyJump by: {', '.join(dependents)}")
393
+ with self.transaction() as connection:
394
+ connection.execute("DELETE FROM servers WHERE id = ?", (current["id"],))
395
+ return current
396
+
397
+ def record_test(
398
+ self,
399
+ identifier: str,
400
+ *,
401
+ status: str,
402
+ latency_ms: int,
403
+ error_code: str | None,
404
+ message: str,
405
+ ) -> dict[str, Any]:
406
+ current = self.get_server(identifier)
407
+ with self.transaction() as connection:
408
+ connection.execute(
409
+ """
410
+ UPDATE servers SET last_test_at = ?, last_test_status = ?, last_test_latency_ms = ?,
411
+ last_test_error_code = ?, last_test_message = ?, updated_at = ? WHERE id = ?
412
+ """,
413
+ (utc_now(), status, latency_ms, error_code, message[:500], utc_now(), current["id"]),
414
+ )
415
+ return self.get_server(current["id"])
416
+
417
+ def get_setting(self, key: str) -> str | None:
418
+ with self.connect() as connection:
419
+ row = connection.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
420
+ return row["value"] if row else None
421
+
422
+ def set_setting(self, key: str, value: str) -> None:
423
+ with self.transaction() as connection:
424
+ connection.execute(
425
+ """
426
+ INSERT INTO settings(key, value, updated_at) VALUES (?, ?, ?)
427
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
428
+ """,
429
+ (key, value, utc_now()),
430
+ )
431
+
432
+ def list_webauthn_credentials(self) -> list[dict[str, Any]]:
433
+ with self.connect() as connection:
434
+ rows = connection.execute("SELECT * FROM webauthn_credentials ORDER BY created_at").fetchall()
435
+ return [
436
+ {
437
+ "credential_id": row["credential_id"],
438
+ "public_key": row["public_key"],
439
+ "sign_count": row["sign_count"],
440
+ "transports": json.loads(row["transports"] or "[]"),
441
+ "device_type": row["device_type"],
442
+ "backed_up": bool(row["backed_up"]),
443
+ }
444
+ for row in rows
445
+ ]
446
+
447
+ def save_webauthn_credential(self, credential: dict[str, Any]) -> None:
448
+ now = utc_now()
449
+ with self.transaction() as connection:
450
+ connection.execute(
451
+ """
452
+ INSERT INTO webauthn_credentials
453
+ (credential_id, public_key, sign_count, transports, device_type, backed_up, created_at, updated_at)
454
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
455
+ ON CONFLICT(credential_id) DO UPDATE SET public_key = excluded.public_key,
456
+ sign_count = excluded.sign_count, transports = excluded.transports,
457
+ device_type = excluded.device_type, backed_up = excluded.backed_up,
458
+ updated_at = excluded.updated_at
459
+ """,
460
+ (
461
+ credential["credential_id"],
462
+ credential["public_key"],
463
+ int(credential.get("sign_count", 0)),
464
+ json.dumps(credential.get("transports", [])),
465
+ credential.get("device_type"),
466
+ int(bool(credential.get("backed_up"))),
467
+ now,
468
+ now,
469
+ ),
470
+ )
471
+
472
+ def update_webauthn_sign_count(self, credential_id: str, sign_count: int) -> None:
473
+ with self.transaction() as connection:
474
+ connection.execute(
475
+ "UPDATE webauthn_credentials SET sign_count = ?, updated_at = ? WHERE credential_id = ?",
476
+ (sign_count, utc_now(), credential_id),
477
+ )
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import glob
4
+ import os
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .db import ConflictError, Database, NotFoundError
10
+ from .paths import original_ssh_config_path
11
+ from .ssh_config import split_config_line
12
+ from .validation import ValidationError, validate_alias
13
+
14
+
15
+ class ImportError(RuntimeError):
16
+ pass
17
+
18
+
19
+ def _read_config_aliases(config: Path, seen: set[Path] | None = None) -> tuple[list[str], list[str]]:
20
+ config = config.expanduser().resolve()
21
+ seen = seen or set()
22
+ if config in seen or not config.exists():
23
+ return [], []
24
+ seen.add(config)
25
+ aliases: list[str] = []
26
+ skipped: list[str] = []
27
+ try:
28
+ lines = config.read_text(encoding="utf-8").splitlines()
29
+ except (OSError, UnicodeError) as exc:
30
+ raise ImportError(f"cannot read SSH config {config}: {exc}") from exc
31
+ for line in lines:
32
+ tokens = split_config_line(line)
33
+ if not tokens:
34
+ continue
35
+ directive = tokens[0].casefold()
36
+ if directive == "include":
37
+ for pattern in tokens[1:]:
38
+ expanded = Path(os.path.expanduser(pattern))
39
+ if not expanded.is_absolute():
40
+ expanded = config.parent / expanded
41
+ for match in sorted(glob.glob(str(expanded))):
42
+ child_aliases, child_skipped = _read_config_aliases(Path(match), seen)
43
+ aliases.extend(child_aliases)
44
+ skipped.extend(child_skipped)
45
+ elif directive == "host":
46
+ for token in tokens[1:]:
47
+ if token.startswith("!") or any(mark in token for mark in "*?"):
48
+ skipped.append(token)
49
+ continue
50
+ try:
51
+ aliases.append(validate_alias(token))
52
+ except ValidationError:
53
+ skipped.append(token)
54
+ deduped: list[str] = []
55
+ known: set[str] = set()
56
+ for alias in aliases:
57
+ key = alias.casefold()
58
+ if key not in known:
59
+ deduped.append(alias)
60
+ known.add(key)
61
+ return deduped, sorted(set(skipped), key=str.casefold)
62
+
63
+
64
+ def _resolve_alias(alias: str, config: Path, *, ssh_binary: str = "ssh") -> dict[str, Any]:
65
+ result = subprocess.run(
66
+ [ssh_binary, "-G", "-F", str(config), alias],
67
+ text=True,
68
+ capture_output=True,
69
+ timeout=10,
70
+ check=False,
71
+ )
72
+ if result.returncode != 0:
73
+ raise ImportError(f"ssh -G failed for {alias}: {result.stderr.strip() or 'unknown error'}")
74
+ values: dict[str, list[str]] = {}
75
+ for line in result.stdout.splitlines():
76
+ key, separator, value = line.partition(" ")
77
+ if separator:
78
+ values.setdefault(key.casefold(), []).append(value.strip())
79
+ proxy_value = values.get("proxyjump", ["none"])[0]
80
+ proxy_jumps = [] if proxy_value.casefold() == "none" else [item for item in proxy_value.split(",") if item]
81
+ for jump in proxy_jumps:
82
+ try:
83
+ validate_alias(jump)
84
+ except ValidationError as exc:
85
+ raise ImportError(
86
+ f"{alias} uses a non-alias ProxyJump value ({jump}); create that jump host manually first"
87
+ ) from exc
88
+ return {
89
+ "alias": alias,
90
+ "hostname": values.get("hostname", [alias])[0],
91
+ "port": int(values.get("port", ["22"])[0]),
92
+ "username": values.get("user", [os.environ.get("USER", "unknown")])[0],
93
+ "credential_id": None,
94
+ "proxy_jumps": proxy_jumps,
95
+ "notes": f"Imported from {config}",
96
+ "source": "ssh-config-import",
97
+ }
98
+
99
+
100
+ def preview_import(
101
+ database: Database,
102
+ *,
103
+ config: str | Path | None = None,
104
+ ssh_binary: str = "ssh",
105
+ ) -> dict[str, Any]:
106
+ config_path = Path(config) if config else original_ssh_config_path()
107
+ config_path = config_path.expanduser().resolve()
108
+ aliases, skipped = _read_config_aliases(config_path)
109
+ items: list[dict[str, Any]] = []
110
+ errors: list[dict[str, str]] = []
111
+ for alias in aliases:
112
+ try:
113
+ candidate = _resolve_alias(alias, config_path, ssh_binary=ssh_binary)
114
+ try:
115
+ existing = database.get_server(alias)
116
+ except NotFoundError:
117
+ action = "add"
118
+ else:
119
+ comparable = ("hostname", "port", "username", "proxy_jumps")
120
+ action = "unchanged" if all(existing[key] == candidate[key] for key in comparable) else "conflict"
121
+ items.append({"action": action, "server": candidate})
122
+ except (ImportError, ValidationError, ValueError) as exc:
123
+ errors.append({"alias": alias, "message": str(exc)})
124
+ return {"config": str(config_path), "items": items, "skipped_patterns": skipped, "errors": errors}
125
+
126
+
127
+ def apply_import(
128
+ database: Database,
129
+ preview: dict[str, Any],
130
+ *,
131
+ overwrite: bool = False,
132
+ ) -> dict[str, Any]:
133
+ result = {"added": [], "updated": [], "unchanged": [], "skipped": [], "errors": preview["errors"]}
134
+ for item in preview["items"]:
135
+ action = item["action"]
136
+ server = item["server"]
137
+ try:
138
+ if action == "add":
139
+ result["added"].append(database.create_server(**server)["alias"])
140
+ elif action == "conflict" and overwrite:
141
+ result["updated"].append(database.update_server(server["alias"], **server)["alias"])
142
+ elif action == "unchanged":
143
+ result["unchanged"].append(server["alias"])
144
+ else:
145
+ result["skipped"].append(server["alias"])
146
+ except (ConflictError, ValidationError) as exc:
147
+ result["errors"].append({"alias": server["alias"], "message": str(exc)})
148
+ return result
149
+