turnstone/app/services/ssh_targets.py
pyr0ball b8f766fb74 feat: SSH target manager — GUI editor for remote host configuration (#24)
- app/services/ssh_targets.py: full CRUD service with lazy paramiko
  import, key-path validation, permission warning, and test_connection
- app/db/schema.py: ssh_targets table (id, label, host, port, user,
  key_path, last_tested, last_ok, last_error, timestamps)
- app/rest.py: GET/POST /api/ssh-targets, PATCH/DELETE /{id},
  POST /{id}/test — key contents never returned in any response
- web/src/views/SettingsView.vue: Remote Hosts section with add/edit
  form, inline connection status badges, test-connection flow, delete
  with confirmation; new Set() pattern for reactive sshTesting state
- tests/test_ssh_targets.py: 22 tests — schema, CRUD, validation,
  key-warning, serialization, paramiko-absent path
2026-06-14 15:27:12 -07:00

265 lines
8.1 KiB
Python

"""SSH target registry — persisted in the main SQLite DB.
Targets are stored as path references only. The private key is never
read into the database, logged, or returned by any API response.
"""
from __future__ import annotations
import os
import sqlite3
import stat
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass
class SshTarget:
id: str
label: str
host: str
port: int
user: str
key_path: str
last_tested: str | None
last_ok: bool | None
last_error: str | None
created_at: str
updated_at: str
def _row_to_target(row: tuple) -> SshTarget:
return SshTarget(
id=row[0],
label=row[1],
host=row[2],
port=row[3],
user=row[4],
key_path=row[5],
last_tested=row[6],
last_ok=bool(row[7]) if row[7] is not None else None,
last_error=row[8],
created_at=row[9],
updated_at=row[10],
)
def _now() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
def list_targets(db_path: Path) -> list[SshTarget]:
conn = sqlite3.connect(str(db_path), timeout=10)
rows = conn.execute(
"SELECT id, label, host, port, user, key_path, last_tested, last_ok, last_error, created_at, updated_at "
"FROM ssh_targets ORDER BY label"
).fetchall()
conn.close()
return [_row_to_target(r) for r in rows]
def get_target(db_path: Path, target_id: str) -> SshTarget | None:
conn = sqlite3.connect(str(db_path), timeout=10)
row = conn.execute(
"SELECT id, label, host, port, user, key_path, last_tested, last_ok, last_error, created_at, updated_at "
"FROM ssh_targets WHERE id = ?",
(target_id,),
).fetchone()
conn.close()
return _row_to_target(row) if row else None
def create_target(
db_path: Path,
label: str,
host: str,
port: int,
user: str,
key_path: str,
) -> SshTarget:
resolved = _validate_key_path(key_path)
now = _now()
target_id = str(uuid.uuid4())
conn = sqlite3.connect(str(db_path), timeout=10)
conn.execute(
"INSERT INTO ssh_targets (id, label, host, port, user, key_path, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?)",
(target_id, label, host, port, user, str(resolved), now, now),
)
conn.commit()
conn.close()
return get_target(db_path, target_id) # type: ignore[return-value]
def update_target(
db_path: Path,
target_id: str,
*,
label: str | None = None,
host: str | None = None,
port: int | None = None,
user: str | None = None,
key_path: str | None = None,
) -> SshTarget | None:
existing = get_target(db_path, target_id)
if existing is None:
return None
resolved_key = str(_validate_key_path(key_path)) if key_path else existing.key_path
conn = sqlite3.connect(str(db_path), timeout=10)
conn.execute(
"UPDATE ssh_targets SET label=?, host=?, port=?, user=?, key_path=?, updated_at=? WHERE id=?",
(
label if label is not None else existing.label,
host if host is not None else existing.host,
port if port is not None else existing.port,
user if user is not None else existing.user,
resolved_key,
_now(),
target_id,
),
)
conn.commit()
conn.close()
return get_target(db_path, target_id)
def delete_target(db_path: Path, target_id: str) -> bool:
conn = sqlite3.connect(str(db_path), timeout=10)
cur = conn.execute("DELETE FROM ssh_targets WHERE id = ?", (target_id,))
conn.commit()
conn.close()
return cur.rowcount > 0
# ---------------------------------------------------------------------------
# Test connection
# ---------------------------------------------------------------------------
def test_connection(db_path: Path, target_id: str) -> dict[str, Any]:
"""Attempt an SSH no-op and record the result.
Runs `true` on the remote host — no data is pulled. Returns
{ok: bool, error: str|null, tested_at: str}.
"""
target = get_target(db_path, target_id)
if target is None:
raise KeyError(f"SSH target {target_id!r} not found")
# Lazy import — paramiko is optional
try:
from paramiko import SSHClient, AutoAddPolicy, AuthenticationException, SSHException
except ImportError:
_record_test(db_path, target_id, ok=False, error="paramiko not installed")
return {"ok": False, "error": "paramiko not installed — run: pip install paramiko", "tested_at": _now()}
key_path = str(Path(target.key_path).expanduser())
error: str | None = None
ok = False
try:
client = SSHClient()
client.set_missing_host_key_policy(AutoAddPolicy())
client.connect(
hostname=target.host,
port=target.port,
username=target.user,
key_filename=key_path,
timeout=10,
banner_timeout=10,
)
stdin, stdout, stderr = client.exec_command("true", timeout=10)
exit_code = stdout.channel.recv_exit_status()
client.close()
ok = exit_code == 0
if not ok:
error = f"Remote command exited with code {exit_code}"
except AuthenticationException:
error = f"Authentication failed — check key path and remote authorized_keys"
except SSHException as exc:
error = f"SSH error: {exc}"
except OSError as exc:
error = f"Connection failed: {exc}"
except Exception as exc:
error = f"Unexpected error: {exc}"
tested_at = _now()
_record_test(db_path, target_id, ok=ok, error=error, tested_at=tested_at)
return {"ok": ok, "error": error, "tested_at": tested_at}
def _record_test(
db_path: Path,
target_id: str,
*,
ok: bool,
error: str | None,
tested_at: str | None = None,
) -> None:
if tested_at is None:
tested_at = _now()
conn = sqlite3.connect(str(db_path), timeout=10)
conn.execute(
"UPDATE ssh_targets SET last_tested=?, last_ok=?, last_error=?, updated_at=? WHERE id=?",
(tested_at, 1 if ok else 0, error, _now(), target_id),
)
conn.commit()
conn.close()
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def _validate_key_path(raw: str) -> Path:
"""Resolve and validate the SSH key path.
Returns the resolved Path. Raises ValueError with a user-readable message
on any problem (does not raise on world-readable — just returns a warning
to the caller so the UI can display it non-blocking).
"""
p = Path(raw).expanduser()
if not p.exists():
raise ValueError(f"Key file not found: {p}")
if not p.is_file():
raise ValueError(f"Key path is not a file: {p}")
return p
def key_path_warning(key_path: str) -> str | None:
"""Return a warning string if the key file has overly permissive mode, else None."""
try:
p = Path(key_path).expanduser()
mode = p.stat().st_mode
if mode & (stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH | stat.S_IWOTH):
perms = oct(mode & 0o777)
return f"Key file permissions are too open ({perms}). SSH may refuse to use it — run: chmod 600 {p}"
except OSError:
pass
return None
def target_to_dict(t: SshTarget, include_warning: bool = False) -> dict[str, Any]:
"""Serialize a target for API responses. Never includes key contents."""
d: dict[str, Any] = {
"id": t.id,
"label": t.label,
"host": t.host,
"port": t.port,
"user": t.user,
"key_path": t.key_path,
"last_tested": t.last_tested,
"last_ok": t.last_ok,
"last_error": t.last_error,
"created_at": t.created_at,
"updated_at": t.updated_at,
}
if include_warning:
d["key_warning"] = key_path_warning(t.key_path)
return d