From 2eb06acb5b8764d7f42b3aec464da521de6718a0 Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 01:01:56 +1000 Subject: [PATCH] feat: SSH card UX, agent version sync, persistent ssh_admin_ok (0.11.5) Silent SSH probe on host card open; manual test feedback auto-hides. Persist ssh_admin_ok in DB (migration 019). After agent update read version from host and refresh UI. --- .../versions/019_host_ssh_admin_status.py | 25 ++ backend/app/api/v1/hosts.py | 37 ++- backend/app/models/host.py | 2 + backend/app/schemas/list_models.py | 2 + backend/app/services/ssh_connect.py | 58 +++- backend/app/version.py | 2 +- backend/tests/test_health.py | 4 +- backend/tests/test_linux_admin_settings.py | 7 + backend/tests/test_ssh_connect.py | 25 +- frontend/src/api.ts | 6 +- frontend/src/style.css | 4 + frontend/src/utils/agentVersion.ts | 11 + frontend/src/utils/hostPatchBus.ts | 16 ++ frontend/src/utils/hostsLiveUpdate.ts | 12 + frontend/src/version.ts | 2 +- frontend/src/views/HostDetailView.vue | 258 ++++++++++++++---- frontend/src/views/HostsView.vue | 22 +- 17 files changed, 423 insertions(+), 70 deletions(-) create mode 100644 backend/alembic/versions/019_host_ssh_admin_status.py create mode 100644 frontend/src/utils/hostPatchBus.ts diff --git a/backend/alembic/versions/019_host_ssh_admin_status.py b/backend/alembic/versions/019_host_ssh_admin_status.py new file mode 100644 index 0000000..9bb6e5b --- /dev/null +++ b/backend/alembic/versions/019_host_ssh_admin_status.py @@ -0,0 +1,25 @@ +"""hosts: persist Linux SSH admin check status + +Revision ID: 019 +Revises: 018 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "019" +down_revision: Union[str, None] = "018" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("hosts", sa.Column("ssh_admin_ok", sa.Boolean(), nullable=True)) + op.add_column("hosts", sa.Column("ssh_admin_checked_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("hosts", "ssh_admin_checked_at") + op.drop_column("hosts", "ssh_admin_ok") diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index 2820171..b348d18 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -3,6 +3,8 @@ from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session +from datetime import datetime, timezone + from app.auth.jwt_auth import get_current_user, require_admin from app.config import get_settings from app.database import get_db @@ -151,9 +153,16 @@ def _host_detail_from_model( inventory=host.inventory if isinstance(host.inventory, dict) else None, inventory_updated_at=host.inventory_updated_at, created_at=host.created_at, + ssh_admin_ok=host.ssh_admin_ok, + ssh_admin_checked_at=host.ssh_admin_checked_at, ) +def _set_ssh_admin_status(host: Host, ok: bool) -> None: + host.ssh_admin_ok = ok + host.ssh_admin_checked_at = datetime.now(timezone.utc) + + @router.get("/{host_id}", response_model=HostDetail) def get_host( host_id: int, @@ -189,9 +198,16 @@ class HostSshActionResponse(BaseModel): stdout: str | None = None stderr: str | None = None exit_code: int | None = None + product_version: str | None = None + ssh_admin_ok: bool | None = None -def _ssh_action_response(result: SshCommandResult, *, attempts: list[str] | None = None) -> HostSshActionResponse: +def _ssh_action_response( + result: SshCommandResult, + *, + attempts: list[str] | None = None, + ssh_admin_ok: bool | None = None, +) -> HostSshActionResponse: message = result.message if attempts: message = f"{message} (пробовали: {', '.join(attempts)})" @@ -202,6 +218,8 @@ def _ssh_action_response(result: SshCommandResult, *, attempts: list[str] | None stdout=result.stdout or None, stderr=result.stderr or None, exit_code=result.exit_code, + product_version=result.agent_version, + ssh_admin_ok=ssh_admin_ok, ) @@ -313,7 +331,10 @@ def test_host_ssh( if not cfg.configured: raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") - return _run_ssh_action_on_host(host, cfg, action=test_ssh_connection) + response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection) + _set_ssh_admin_status(host, response.ok) + db.commit() + return response.model_copy(update={"ssh_admin_ok": host.ssh_admin_ok}) @router.post("/{host_id}/actions/agent-update", response_model=HostSshActionResponse) @@ -330,7 +351,17 @@ def update_host_agent_via_ssh( if not cfg.configured: raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") - return _run_ssh_action_on_host(host, cfg, action=run_ssh_monitor_update) + response = _run_ssh_action_on_host(host, cfg, action=run_ssh_monitor_update) + _set_ssh_admin_status(host, response.ok) + if response.ok and response.product_version: + host.product_version = response.product_version + db.commit() + return response.model_copy( + update={ + "product_version": host.product_version if response.ok else response.product_version, + "ssh_admin_ok": host.ssh_admin_ok, + } + ) @router.delete("/{host_id}", response_model=HostDeleteResponse) diff --git a/backend/app/models/host.py b/backend/app/models/host.py index 1d3b0e0..88ba303 100644 --- a/backend/app/models/host.py +++ b/backend/app/models/host.py @@ -24,6 +24,8 @@ class Host(Base): inventory: Mapped[dict | None] = mapped_column(JSONB) inventory_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) use_sac_mode: Mapped[str | None] = mapped_column(String(32)) + ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True) + ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/schemas/list_models.py b/backend/app/schemas/list_models.py index 819fdd2..fa3466f 100644 --- a/backend/app/schemas/list_models.py +++ b/backend/app/schemas/list_models.py @@ -30,6 +30,8 @@ class HostDetail(HostSummary): inventory: dict | None = None inventory_updated_at: datetime | None = None created_at: datetime | None = None + ssh_admin_ok: bool | None = None + ssh_admin_checked_at: datetime | None = None class HostListResponse(BaseModel): diff --git a/backend/app/services/ssh_connect.py b/backend/app/services/ssh_connect.py index 3ee892d..373e655 100644 --- a/backend/app/services/ssh_connect.py +++ b/backend/app/services/ssh_connect.py @@ -9,7 +9,13 @@ from dataclasses import dataclass from app.models import Host SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh" +SSH_MONITOR_BINARY = "/usr/local/bin/ssh-monitor" +SSH_MONITOR_VERSION_CMD = ( + f"grep -m1 '^SSH_MONITOR_VERSION=' {SSH_MONITOR_BINARY} 2>/dev/null " + "| sed -E 's/^SSH_MONITOR_VERSION=[\"'\\'']*([^\"'\\'']+)[\"'\\'']*$/\\1/'" +) SSH_OUTPUT_MAX_LEN = 12_000 +_AGENT_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:-SAC)?)", re.IGNORECASE) _IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$") @@ -33,6 +39,7 @@ class SshCommandResult: stdout: str = "" stderr: str = "" exit_code: int | None = None + agent_version: str | None = None def is_linux_host(host: Host) -> bool: @@ -233,6 +240,37 @@ def test_ssh_connection( return result +def parse_ssh_monitor_version_text(text: str) -> str | None: + if not text: + return None + match = _AGENT_VERSION_RE.search(text) + if not match: + return None + return match.group(1) + + +def probe_ssh_monitor_version( + *, + target: str, + user: str, + password: str, +) -> str | None: + result = run_ssh_command( + target=target, + user=user, + password=password, + remote_cmd=SSH_MONITOR_VERSION_CMD, + command_timeout_sec=30, + ) + if result.ok and result.stdout.strip(): + version = parse_ssh_monitor_version_text(result.stdout) + if version: + return version + if result.stdout or result.stderr: + return parse_ssh_monitor_version_text(f"{result.stdout}\n{result.stderr}") + return None + + def run_ssh_monitor_update( *, target: str, @@ -258,7 +296,7 @@ def run_ssh_monitor_update( exit_code=probe.exit_code, ) - return run_ssh_command( + updated = run_ssh_command( target=target, user=user, password=password, @@ -266,3 +304,21 @@ def run_ssh_monitor_update( command_timeout_sec=900, need_root=True, ) + if not updated.ok: + return updated + + version = probe_ssh_monitor_version(target=target, user=user, password=password) + if not version: + version = parse_ssh_monitor_version_text(updated.stdout) + if version: + message = f"{updated.message}\nagent version: {version}" + return SshCommandResult( + ok=True, + message=message, + target=updated.target, + stdout=updated.stdout, + stderr=updated.stderr, + exit_code=updated.exit_code, + agent_version=version, + ) + return updated diff --git a/backend/app/version.py b/backend/app/version.py index a10cd42..71c3a51 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.11.2" +APP_VERSION = "0.11.5" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 20321e7..a0605be 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL def test_version_constants(): - assert APP_VERSION == "0.11.2" + assert APP_VERSION == "0.11.5" assert APP_NAME == "Security Alert Center" - assert APP_VERSION_LABEL == "Security Alert Center v.0.11.2" + assert APP_VERSION_LABEL == "Security Alert Center v.0.11.5" diff --git a/backend/tests/test_linux_admin_settings.py b/backend/tests/test_linux_admin_settings.py index 70bcda8..0d17f80 100644 --- a/backend/tests/test_linux_admin_settings.py +++ b/backend/tests/test_linux_admin_settings.py @@ -116,6 +116,11 @@ def test_host_ssh_test_success(jwt_headers, client, db_session, monkeypatch): assert body["ok"] is True assert "hostname=ubabuba" in body["message"] assert body["target"] == "ubabuba" + assert body["ssh_admin_ok"] is True + + db_session.refresh(host) + assert host.ssh_admin_ok is True + assert host.ssh_admin_checked_at is not None def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch): @@ -149,3 +154,5 @@ def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch) body = response.json() assert body["ok"] is True assert body["target"] == "ubabuba" + if "product_version" in body: + assert body["product_version"] is None or isinstance(body["product_version"], str) diff --git a/backend/tests/test_ssh_connect.py b/backend/tests/test_ssh_connect.py index f7a88f3..eb027cd 100644 --- a/backend/tests/test_ssh_connect.py +++ b/backend/tests/test_ssh_connect.py @@ -182,13 +182,21 @@ def test_run_ssh_monitor_update_runs_script(monkeypatch): target="h", stdout="ready\n", ) - assert kwargs["remote_cmd"] == "/opt/scripts/update_ssh_monitor.sh" - assert kwargs.get("need_root") is True + if calls == 2: + assert kwargs["remote_cmd"] == "/opt/scripts/update_ssh_monitor.sh" + assert kwargs.get("need_root") is True + return ssh_connect.SshCommandResult( + ok=True, + message="SSH OK", + target="ubabuba", + stdout="SUMMARY updated 2.1.0-SAC\n", + exit_code=0, + ) return ssh_connect.SshCommandResult( ok=True, - message="SSH OK", + message="ok", target="ubabuba", - stdout="SUMMARY updated\n", + stdout='2.1.0-SAC\n', exit_code=0, ) @@ -196,4 +204,11 @@ def test_run_ssh_monitor_update_runs_script(monkeypatch): result = run_ssh_monitor_update(target="ubabuba", user="root", password="pw") assert result.ok is True - assert calls == 2 + assert result.agent_version == "2.1.0-SAC" + assert calls == 3 + + +def test_parse_ssh_monitor_version_text(): + assert ssh_connect.parse_ssh_monitor_version_text("SSH_MONITOR_VERSION=2.1.0-SAC") == "2.1.0-SAC" + assert ssh_connect.parse_ssh_monitor_version_text("SUMMARY updated 2.1.1-SAC done") == "2.1.1-SAC" + assert ssh_connect.parse_ssh_monitor_version_text("no version") is None diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ba65c91..6d63932 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -const TOKEN_KEY = "sac_token"; +const TOKEN_KEY = "sac_token"; const ROLE_KEY = "sac_role"; export function getToken(): string | null { @@ -227,6 +227,8 @@ export interface HostSummary { last_daily_report_at: string | null; last_inventory_at?: string | null; agent_status: "online" | "stale" | "unknown"; + ssh_admin_ok?: boolean | null; + ssh_admin_checked_at?: string | null; } export interface HostDetail extends HostSummary { @@ -474,6 +476,8 @@ export interface HostSshActionResult { stdout: string | null; stderr: string | null; exit_code: number | null; + product_version?: string | null; + ssh_admin_ok?: boolean | null; } export function testHostSsh(hostId: number): Promise { diff --git a/frontend/src/style.css b/frontend/src/style.css index 21c2254..49bb974 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -198,6 +198,10 @@ button:disabled { color: #ff6b6b; } +.success { + color: #3fb950; +} + pre { overflow: auto; font-size: 0.8rem; diff --git a/frontend/src/utils/agentVersion.ts b/frontend/src/utils/agentVersion.ts index 16c9d84..8c45f1e 100644 --- a/frontend/src/utils/agentVersion.ts +++ b/frontend/src/utils/agentVersion.ts @@ -42,3 +42,14 @@ export function isAgentVersionOutdated( if (lag === null) return true; return lag >= lagThreshold; } + +export function isAgentVersionNewer( + candidate: string | null | undefined, + current: string | null | undefined, +): boolean { + const next = parseAgentVersion(candidate); + const prev = parseAgentVersion(current); + if (!next) return false; + if (!prev) return true; + return compareVersions(next, prev) > 0; +} diff --git a/frontend/src/utils/hostPatchBus.ts b/frontend/src/utils/hostPatchBus.ts new file mode 100644 index 0000000..c76717a --- /dev/null +++ b/frontend/src/utils/hostPatchBus.ts @@ -0,0 +1,16 @@ +import type { HostDetail } from "../api"; + +type HostPatchListener = (detail: HostDetail) => void; + +const listeners = new Set(); + +export function subscribeHostPatch(listener: HostPatchListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function emitHostPatch(detail: HostDetail): void { + for (const listener of listeners) { + listener(detail); + } +} diff --git a/frontend/src/utils/hostsLiveUpdate.ts b/frontend/src/utils/hostsLiveUpdate.ts index fd027fb..9842275 100644 --- a/frontend/src/utils/hostsLiveUpdate.ts +++ b/frontend/src/utils/hostsLiveUpdate.ts @@ -1,4 +1,5 @@ import type { HostDetail, HostSummary } from "../api"; +import { isAgentVersionNewer } from "./agentVersion"; /** Обновляет поля строки списка хостов из GET /hosts/{id} (без перезагрузки таблицы). */ export function patchHostSummaryFromDetail(target: HostSummary, detail: HostDetail): void { @@ -13,3 +14,14 @@ export function patchHostSummaryFromDetail(target: HostSummary, detail: HostDeta target.last_inventory_at = detail.last_inventory_at; target.agent_status = detail.agent_status; } + +export function bumpLatestAgentVersion( + latest: Record | undefined, + product: string, + version: string | null | undefined, +): void { + if (!latest || !version) return; + if (isAgentVersionNewer(version, latest[product])) { + latest[product] = version; + } +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 865ea5c..03d4ebe 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.11.2"; +export const APP_VERSION = "0.11.5"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/HostDetailView.vue b/frontend/src/views/HostDetailView.vue index f730d67..d154b73 100644 --- a/frontend/src/views/HostDetailView.vue +++ b/frontend/src/views/HostDetailView.vue @@ -59,24 +59,39 @@

{{ winRmTestMessage }}

- - -

{{ sshActionMessage }}

-
{{ sshActionOutput }}
+
+
+ + +
+ +
+
+

{{ sshTestMessage }}

+
{{ sshTestOutput }}
+
+
+

{{ agentUpdateMessage }}

+
{{ agentUpdateOutput }}
+
@@ -189,7 +204,7 @@