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
ssh_server_manager/db.py
ADDED
|
@@ -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
|
+
|