diff --git a/app/db/schema.py b/app/db/schema.py index 5408845..3785adc 100644 --- a/app/db/schema.py +++ b/app/db/schema.py @@ -144,6 +144,20 @@ CREATE INDEX IF NOT EXISTS idx_blocklist_device ON blocklist_candidates(source_ CREATE INDEX IF NOT EXISTS idx_blocklist_status ON blocklist_candidates(status); CREATE INDEX IF NOT EXISTS idx_blocklist_domain ON blocklist_candidates(domain_or_ip); CREATE INDEX IF NOT EXISTS idx_blocklist_tenant ON blocklist_candidates(tenant_id); + +CREATE TABLE IF NOT EXISTS ssh_targets ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + user TEXT NOT NULL, + key_path TEXT NOT NULL, + last_tested TEXT, + last_ok INTEGER DEFAULT NULL, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); """ _CONTEXT_SCHEMA_SQLITE = """ diff --git a/app/rest.py b/app/rest.py index c0efdc9..7d53672 100644 --- a/app/rest.py +++ b/app/rest.py @@ -55,6 +55,7 @@ from app.services.pihole import PiholeClient from app.services.discover import discover_all, build_sources_yaml, validate_source, scan_log_directories from app.services.nl_source import interpret as _nl_interpret from app.services import orchard as _orchard +from app.services import ssh_targets as _ssh_targets from app.services.incidents import ( build_bundle, create_incident, @@ -801,6 +802,89 @@ class BatchGleanRequest(BaseModel): entries: list[BatchEntry] +# ── SSH target manager ───────────────────────────────────────────────────── + +class SshTargetCreate(BaseModel): + label: str + host: str + port: int = 22 + user: str + key_path: str + + +class SshTargetUpdate(BaseModel): + label: str | None = None + host: str | None = None + port: int | None = None + user: str | None = None + key_path: str | None = None + + +@router.get("/api/ssh-targets") +def list_ssh_targets() -> dict: + """List all configured SSH targets (never returns key contents).""" + targets = _ssh_targets.list_targets(DB_PATH) + return {"targets": [_ssh_targets.target_to_dict(t, include_warning=True) for t in targets]} + + +@router.post("/api/ssh-targets") +def create_ssh_target(body: SshTargetCreate) -> dict: + """Create a new SSH target.""" + try: + target = _ssh_targets.create_target( + DB_PATH, + label=body.label, + host=body.host, + port=body.port, + user=body.user, + key_path=body.key_path, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + d = _ssh_targets.target_to_dict(target, include_warning=True) + return d + + +@router.patch("/api/ssh-targets/{target_id}") +def update_ssh_target(target_id: str, body: SshTargetUpdate) -> dict: + """Update an existing SSH target.""" + try: + target = _ssh_targets.update_target( + DB_PATH, + target_id, + label=body.label, + host=body.host, + port=body.port, + user=body.user, + key_path=body.key_path, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + if target is None: + raise HTTPException(status_code=404, detail=f"SSH target {target_id!r} not found") + return _ssh_targets.target_to_dict(target, include_warning=True) + + +@router.delete("/api/ssh-targets/{target_id}") +def delete_ssh_target(target_id: str) -> dict: + """Remove an SSH target.""" + if not _ssh_targets.delete_target(DB_PATH, target_id): + raise HTTPException(status_code=404, detail=f"SSH target {target_id!r} not found") + return {"deleted": target_id} + + +@router.post("/api/ssh-targets/{target_id}/test") +def test_ssh_target(target_id: str) -> dict: + """Test an SSH connection by running a no-op remote command. + + Records the result in the DB so the UI can show a persistent status badge. + """ + try: + return _ssh_targets.test_connection(DB_PATH, target_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + # ── Setup / Onboarding wizard ────────────────────────────────────────────── class SetupWriteBody(BaseModel): diff --git a/app/services/ssh_targets.py b/app/services/ssh_targets.py new file mode 100644 index 0000000..4f55a30 --- /dev/null +++ b/app/services/ssh_targets.py @@ -0,0 +1,265 @@ +"""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 diff --git a/tests/test_ssh_targets.py b/tests/test_ssh_targets.py new file mode 100644 index 0000000..5844578 --- /dev/null +++ b/tests/test_ssh_targets.py @@ -0,0 +1,245 @@ +"""Tests for ssh_targets service — CRUD, validation, serialization.""" +from __future__ import annotations + +import stat +import sqlite3 +from pathlib import Path + +import pytest + + +def _make_db(tmp_path: Path) -> Path: + """Create a minimal DB with the ssh_targets table via ensure_schema.""" + from app.glean.pipeline import ensure_schema + db = tmp_path / "test.db" + ensure_schema(db) + return db + + +def _make_key(tmp_path: Path, mode: int = 0o600) -> Path: + """Write a fake SSH private key file with the given permission mode.""" + key = tmp_path / "id_ed25519" + key.write_text("-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----\n") + key.chmod(mode) + return key + + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + +class TestSchema: + def test_ssh_targets_table_exists(self, tmp_path): + db = _make_db(tmp_path) + conn = sqlite3.connect(str(db)) + tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()} + assert "ssh_targets" in tables + conn.close() + + def test_ssh_targets_columns(self, tmp_path): + db = _make_db(tmp_path) + conn = sqlite3.connect(str(db)) + cols = {r[1] for r in conn.execute("PRAGMA table_info(ssh_targets)").fetchall()} + assert cols >= {"id", "label", "host", "port", "user", "key_path", + "last_tested", "last_ok", "last_error", "created_at", "updated_at"} + conn.close() + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + +class TestCrud: + def test_create_and_list(self, tmp_path): + from app.services.ssh_targets import create_target, list_targets + db = _make_db(tmp_path) + key = _make_key(tmp_path) + t = create_target(db, label="server-01", host="10.0.0.1", port=22, user="alan", key_path=str(key)) + assert t.label == "server-01" + assert t.host == "10.0.0.1" + assert t.port == 22 + assert t.user == "alan" + targets = list_targets(db) + assert len(targets) == 1 + assert targets[0].id == t.id + + def test_create_resolves_tilde(self, tmp_path): + from app.services.ssh_targets import create_target + from unittest.mock import patch + db = _make_db(tmp_path) + key = _make_key(tmp_path) + with patch("pathlib.Path.expanduser", return_value=key): + t = create_target(db, label="x", host="h", port=22, user="u", key_path="~/id_ed25519") + assert "~" not in t.key_path + + def test_get_returns_none_for_missing(self, tmp_path): + from app.services.ssh_targets import get_target + db = _make_db(tmp_path) + assert get_target(db, "nonexistent-id") is None + + def test_update_partial(self, tmp_path): + from app.services.ssh_targets import create_target, update_target + db = _make_db(tmp_path) + key = _make_key(tmp_path) + t = create_target(db, label="old-label", host="10.0.0.1", port=22, user="alan", key_path=str(key)) + updated = update_target(db, t.id, label="new-label") + assert updated is not None + assert updated.label == "new-label" + assert updated.host == "10.0.0.1" # unchanged + + def test_update_missing_target_returns_none(self, tmp_path): + from app.services.ssh_targets import update_target + db = _make_db(tmp_path) + assert update_target(db, "no-such-id", label="x") is None + + def test_delete_returns_true_on_success(self, tmp_path): + from app.services.ssh_targets import create_target, delete_target, list_targets + db = _make_db(tmp_path) + key = _make_key(tmp_path) + t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key)) + assert delete_target(db, t.id) is True + assert list_targets(db) == [] + + def test_delete_returns_false_for_missing(self, tmp_path): + from app.services.ssh_targets import delete_target + db = _make_db(tmp_path) + assert delete_target(db, "no-such-id") is False + + def test_list_sorted_by_label(self, tmp_path): + from app.services.ssh_targets import create_target, list_targets + db = _make_db(tmp_path) + key = _make_key(tmp_path) + create_target(db, label="zebra", host="h", port=22, user="u", key_path=str(key)) + create_target(db, label="alpha", host="h", port=22, user="u", key_path=str(key)) + labels = [t.label for t in list_targets(db)] + assert labels == ["alpha", "zebra"] + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +class TestValidation: + def test_create_raises_on_missing_key_file(self, tmp_path): + from app.services.ssh_targets import create_target + db = _make_db(tmp_path) + with pytest.raises(ValueError, match="not found"): + create_target(db, label="x", host="h", port=22, user="u", key_path="/nonexistent/key") + + def test_create_raises_on_directory_as_key(self, tmp_path): + from app.services.ssh_targets import create_target + db = _make_db(tmp_path) + with pytest.raises(ValueError, match="not a file"): + create_target(db, label="x", host="h", port=22, user="u", key_path=str(tmp_path)) + + def test_update_raises_on_bad_key_path(self, tmp_path): + from app.services.ssh_targets import create_target, update_target + db = _make_db(tmp_path) + key = _make_key(tmp_path) + t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key)) + with pytest.raises(ValueError): + update_target(db, t.id, key_path="/does/not/exist") + + +# --------------------------------------------------------------------------- +# Key warning +# --------------------------------------------------------------------------- + +class TestKeyWarning: + def test_no_warning_for_600(self, tmp_path): + from app.services.ssh_targets import key_path_warning + key = _make_key(tmp_path, mode=0o600) + assert key_path_warning(str(key)) is None + + def test_warning_for_644(self, tmp_path): + from app.services.ssh_targets import key_path_warning + key = _make_key(tmp_path, mode=0o644) + warning = key_path_warning(str(key)) + assert warning is not None + assert "chmod 600" in warning + + def test_no_warning_for_nonexistent_file(self, tmp_path): + from app.services.ssh_targets import key_path_warning + # Should not raise — just return None + result = key_path_warning("/nonexistent/path") + assert result is None + + +# --------------------------------------------------------------------------- +# Serialization +# --------------------------------------------------------------------------- + +class TestTargetToDict: + def test_basic_fields_present(self, tmp_path): + from app.services.ssh_targets import create_target, target_to_dict + db = _make_db(tmp_path) + key = _make_key(tmp_path) + t = create_target(db, label="server", host="10.0.0.1", port=2222, user="admin", key_path=str(key)) + d = target_to_dict(t) + assert d["label"] == "server" + assert d["host"] == "10.0.0.1" + assert d["port"] == 2222 + assert d["user"] == "admin" + assert "key_path" in d + assert "key_warning" not in d # not included by default + + def test_key_contents_never_in_dict(self, tmp_path): + from app.services.ssh_targets import create_target, target_to_dict + db = _make_db(tmp_path) + key = _make_key(tmp_path) + t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key)) + d = target_to_dict(t, include_warning=True) + for v in d.values(): + if isinstance(v, str): + assert "BEGIN" not in v, "Key contents must never be included in serialized output" + + def test_include_warning_adds_field(self, tmp_path): + from app.services.ssh_targets import create_target, target_to_dict + db = _make_db(tmp_path) + key = _make_key(tmp_path, mode=0o644) + t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key)) + d = target_to_dict(t, include_warning=True) + assert "key_warning" in d + assert d["key_warning"] is not None + + def test_last_ok_is_none_before_test(self, tmp_path): + from app.services.ssh_targets import create_target, target_to_dict + db = _make_db(tmp_path) + key = _make_key(tmp_path) + t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key)) + d = target_to_dict(t) + assert d["last_ok"] is None + assert d["last_tested"] is None + + +# --------------------------------------------------------------------------- +# test_connection (paramiko not available path) +# --------------------------------------------------------------------------- + +class TestConnectionNoParamiko: + def test_returns_error_when_paramiko_missing(self, tmp_path): + from app.services.ssh_targets import create_target, test_connection + import sys + db = _make_db(tmp_path) + key = _make_key(tmp_path) + t = create_target(db, label="x", host="127.0.0.1", port=22, user="u", key_path=str(key)) + + # Temporarily hide paramiko from the import system + original = sys.modules.get("paramiko") + sys.modules["paramiko"] = None # type: ignore[assignment] + try: + result = test_connection(db, t.id) + finally: + if original is None: + del sys.modules["paramiko"] + else: + sys.modules["paramiko"] = original + + assert result["ok"] is False + assert "paramiko" in result["error"].lower() + + def test_raises_key_error_for_missing_target(self, tmp_path): + from app.services.ssh_targets import test_connection + db = _make_db(tmp_path) + with pytest.raises(KeyError): + test_connection(db, "no-such-id") diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 2233533..7ea8ca2 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -282,6 +282,118 @@ + +
+

Remote Hosts

+

+ SSH hosts to pull logs from. Private keys are stored as path references only — key contents are never read or transmitted. +

+ + +
+
+
+
+
+ {{ t.label }} + {{ t.user }}@{{ t.host }}:{{ t.port }} + + Connected + Unreachable + Not tested +
+

{{ t.key_path }}

+

⚠ {{ t.key_warning }}

+ +

+ {{ sshTestResults[t.id]!.ok ? 'Connection OK' : sshTestResults[t.id]!.error }} +

+
+
+ + + +
+
+
+
+

+ No remote hosts configured. Add an SSH host to pull logs from remote machines without manual file exports. +

+ + +
+

{{ sshForm.editId ? 'Edit host' : 'Add remote host' }}

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

{{ sshFormError }}

+
+ + +
+
+ + +
+

(null) const newRule = ref({ name: '', pattern: '', override_severity: 'WARN', enabled: true }) + +// SSH targets +const sshTargets = ref([]) +const sshTestResults = ref>({}) +const sshTesting = ref>(new Set()) +const sshFormSaving = ref(false) +const sshFormError = ref(null) +const sshForm = ref({ + open: false, + editId: null as string | null, + label: '', + host: '', + port: 22, + user: '', + key_path: '', +}) const entryPointBtnRefs = ref([]) const entryPointOptions = [ @@ -391,6 +532,7 @@ onMounted(async () => { const res = await fetch(`${BASE}/api/settings`) if (res.ok) prefs.value = await res.json() } catch { /* non-critical — defaults stay */ } + await loadSshTargets() }) async function patch(body: Partial) { @@ -490,4 +632,78 @@ async function testPihole() { piholeStatus.value = { ok: false, msg: 'Network error' } } } + +// --- SSH target management --- + +async function loadSshTargets() { + try { + const res = await fetch(`${BASE}/api/ssh-targets`) + if (res.ok) sshTargets.value = await res.json() + } catch { /* non-critical */ } +} + +async function testSshTarget(id: string) { + sshTesting.value = new Set([...sshTesting.value, id]) + try { + const res = await fetch(`${BASE}/api/ssh-targets/${id}/test`, { method: 'POST' }) + const data = await res.json() + sshTestResults.value = { ...sshTestResults.value, [id]: { ok: data.ok, error: data.error ?? null } } + // Refresh list so last_ok badge updates + await loadSshTargets() + } catch { + sshTestResults.value = { ...sshTestResults.value, [id]: { ok: false, error: 'Network error' } } + } finally { + const next = new Set(sshTesting.value) + next.delete(id) + sshTesting.value = next + } +} + +function editSshTarget(t: SshTarget) { + sshFormError.value = null + sshForm.value = { open: true, editId: t.id, label: t.label, host: t.host, port: t.port, user: t.user, key_path: t.key_path } +} + +async function deleteSshTarget(id: string, label: string) { + if (!confirm(`Delete remote host "${label}"?`)) return + try { + await fetch(`${BASE}/api/ssh-targets/${id}`, { method: 'DELETE' }) + await loadSshTargets() + } catch { /* ignore */ } +} + +async function saveSshTarget() { + const f = sshForm.value + if (!f.label.trim() || !f.host.trim() || !f.user.trim() || !f.key_path.trim()) { + sshFormError.value = 'All fields are required' + return + } + sshFormSaving.value = true + sshFormError.value = null + try { + const url = f.editId ? `${BASE}/api/ssh-targets/${f.editId}` : `${BASE}/api/ssh-targets` + const method = f.editId ? 'PATCH' : 'POST' + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: f.label, host: f.host, port: f.port, user: f.user, key_path: f.key_path }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: 'Save failed' })) + sshFormError.value = err.detail ?? 'Save failed' + return + } + closeSshForm() + await loadSshTargets() + } catch { + sshFormError.value = 'Network error' + } finally { + sshFormSaving.value = false + } +} + +function closeSshForm() { + sshForm.value = { open: false, editId: null, label: '', host: '', port: 22, user: '', key_path: '' } + sshFormError.value = null +}