diff --git a/backend/alembic/versions/022_host_remote_action.py b/backend/alembic/versions/022_host_remote_action.py new file mode 100644 index 0000000..93151c2 --- /dev/null +++ b/backend/alembic/versions/022_host_remote_action.py @@ -0,0 +1,27 @@ +"""host remote_action JSON for background SSH/WinRM jobs + +Revision ID: 022_host_remote_action +Revises: 021_agent_git_release +Create Date: 2026-06-21 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "022_host_remote_action" +down_revision = "021_agent_git_release" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "hosts", + sa.Column("remote_action", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("hosts", "remote_action") diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index 8227c8a..c6a49b8 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -1,10 +1,9 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session from datetime import datetime, timezone -from functools import partial from app.auth.jwt_auth import get_current_user, require_admin from app.config import get_settings @@ -18,10 +17,16 @@ from app.services.agent_version import ( from app.services.agent_git_release import get_git_release_versions from app.services.agent_update import ( AgentUpdateNotAllowedError, - execute_agent_update_fallback, host_version_status, request_agent_update, ) +from app.services.host_remote_actions import ( + RemoteActionAlreadyRunningError, + get_remote_action_status, + run_agent_fallback_action, + run_ssh_monitor_update_action, + start_host_remote_action, +) from app.services.agent_update_settings import get_effective_agent_update_config from app.services.agent_host_config import get_host_agent_settings, update_host_agent_config from app.services.host_delete import delete_host_and_related @@ -264,6 +269,32 @@ class HostAgentConfigResponse(BaseModel): settings: dict[str, object] +class HostRemoteActionStartResponse(BaseModel): + status: str + host_id: int + title: str + message: str + + +class HostRemoteActionJobResponse(BaseModel): + active: bool + host_id: int + hostname: str | None = None + status: str | None = None + title: str = "" + message: str = "" + stdout: str | None = None + stderr: str | None = None + output: str | None = None + ok: bool | None = None + exit_code: int | None = None + product_version: str | None = None + target: str | None = None + agent_update_state: str | None = None + started_at: str | None = None + finished_at: str | None = None + + def _ssh_action_response( result: SshCommandResult, *, @@ -399,12 +430,16 @@ def test_host_ssh( return response.model_copy(update={"ssh_admin_ok": host.ssh_admin_ok}) -@router.post("/{host_id}/actions/agent-update", response_model=HostSshActionResponse) +@router.post( + "/{host_id}/actions/agent-update", + response_model=HostRemoteActionStartResponse, + status_code=status.HTTP_202_ACCEPTED, +) def update_host_agent_via_ssh( host_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), -) -> HostSshActionResponse: +) -> HostRemoteActionStartResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") @@ -413,23 +448,21 @@ def update_host_agent_via_ssh( if not cfg.configured: raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") - agent_cfg = get_effective_agent_update_config(db) - repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None - branch = (agent_cfg.git_branch or "main").strip() or "main" - response = _run_ssh_action_on_host( - host, - cfg, - action=partial(run_ssh_monitor_update, repo_url=repo_url, git_branch=branch), - ) - _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, - } + title = "Обновление ssh-monitor (SSH)" + try: + start_host_remote_action( + db, + host, + title=title, + runner=run_ssh_monitor_update_action, + ) + except RemoteActionAlreadyRunningError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return HostRemoteActionStartResponse( + status="running", + host_id=host.id, + title=title, + message="Обновление запущено на сервере SAC и продолжится в фоне", ) @@ -455,30 +488,61 @@ def post_request_agent_update( ) -@router.post("/{host_id}/actions/agent-update-fallback", response_model=HostSshActionResponse) +@router.post( + "/{host_id}/actions/agent-update-fallback", + response_model=HostRemoteActionStartResponse, + status_code=status.HTTP_202_ACCEPTED, +) def post_agent_update_fallback( host_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), -) -> HostSshActionResponse: +) -> HostRemoteActionStartResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") - result = execute_agent_update_fallback(db, host) - db.commit() - return HostSshActionResponse( - ok=result.ok, - message=result.message, - target=result.target or host.hostname, - stdout=result.stdout or None, - stderr=result.stderr or None, - exit_code=result.exit_code, - product_version=result.product_version or (host.product_version if result.ok else None), - ssh_admin_ok=host.ssh_admin_ok, + from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH + from app.services.ssh_connect import is_linux_host + from app.services.winrm_connect import is_windows_host + + product = (host.product or "").strip() + if product == PRODUCT_RDP or is_windows_host(host): + title = "Обновление через WinRM" + elif product == PRODUCT_SSH or is_linux_host(host): + title = "Fallback SSH (ssh-monitor)" + else: + title = "Обновление агента (fallback)" + + try: + start_host_remote_action( + db, + host, + title=title, + runner=run_agent_fallback_action, + ) + except RemoteActionAlreadyRunningError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return HostRemoteActionStartResponse( + status="running", + host_id=host.id, + title=title, + message="Обновление запущено на сервере SAC и продолжится в фоне", ) +@router.get("/{host_id}/actions/remote-job", response_model=HostRemoteActionJobResponse) +def get_host_remote_job( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostRemoteActionJobResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + return HostRemoteActionJobResponse(**get_remote_action_status(host)) + + @router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse) def patch_host_agent_config( host_id: int, diff --git a/backend/app/models/host.py b/backend/app/models/host.py index 6a8a607..f65c391 100644 --- a/backend/app/models/host.py +++ b/backend/app/models/host.py @@ -34,6 +34,7 @@ class Host(Base): agent_update_state: Mapped[str | None] = mapped_column(String(32)) agent_update_last_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) agent_update_last_error: Mapped[str | None] = mapped_column(Text) + remote_action: Mapped[dict | None] = mapped_column(JSONB) 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/services/host_remote_actions.py b/backend/app/services/host_remote_actions.py new file mode 100644 index 0000000..4781ad9 --- /dev/null +++ b/backend/app/services/host_remote_actions.py @@ -0,0 +1,245 @@ +"""Background SSH/WinRM host actions (survives UI navigation and multi-worker API).""" + +from __future__ import annotations + +import logging +import os +import threading +from datetime import datetime, timezone +from typing import Any, Callable + +from sqlalchemy.orm import Session + +from app.database import SessionLocal +from app.models import Host +from app.services.agent_update import execute_agent_update_fallback +from app.services.agent_update_types import AgentUpdateFallbackResult +from app.services.linux_admin_settings import get_effective_linux_admin_config +from app.services.agent_update_settings import get_effective_agent_update_config +from app.services.ssh_connect import iter_ssh_targets, run_ssh_monitor_update, SshCommandResult + +logger = logging.getLogger(__name__) + +ActionRunner = Callable[[Session, Host], AgentUpdateFallbackResult | SshCommandResult] + +_lock = threading.Lock() +_running: set[int] = set() + + +class RemoteActionAlreadyRunningError(Exception): + pass + + +def _run_inline() -> bool: + return os.environ.get("SAC_REMOTE_ACTION_INLINE", "").strip().lower() in ("1", "true", "yes") + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _format_output(result: AgentUpdateFallbackResult | SshCommandResult) -> str: + parts: list[str] = [] + stdout = (getattr(result, "stdout", None) or "").strip() + stderr = (getattr(result, "stderr", None) or "").strip() + if stdout: + parts.append(stdout) + if stderr: + parts.append(stderr) + exit_code = getattr(result, "exit_code", None) + if exit_code is not None: + parts.append(f"exit code: {exit_code}") + return "\n\n".join(parts) + + +def _result_payload( + result: AgentUpdateFallbackResult | SshCommandResult, + *, + title: str, + started_at: str, +) -> dict[str, Any]: + finished = _utcnow().isoformat() + product_version = getattr(result, "product_version", None) or getattr(result, "agent_version", None) + return { + "title": title, + "status": "success" if result.ok else "failed", + "message": result.message, + "stdout": getattr(result, "stdout", None) or "", + "stderr": getattr(result, "stderr", None) or "", + "output": _format_output(result), + "ok": result.ok, + "exit_code": getattr(result, "exit_code", None), + "product_version": product_version, + "target": getattr(result, "target", None) or "", + "started_at": started_at, + "finished_at": finished, + } + + +def _mark_running(db: Session, host: Host, *, title: str) -> str: + started_at = _utcnow().isoformat() + host.agent_update_state = "running" + host.agent_update_last_error = None + host.remote_action = { + "title": title, + "status": "running", + "message": "Подключение к хосту и выполнение команды…", + "stdout": "", + "stderr": "", + "output": "", + "ok": None, + "exit_code": None, + "product_version": None, + "target": host.hostname, + "started_at": started_at, + "finished_at": None, + } + db.flush() + return started_at + + +def _apply_ssh_update_result(db: Session, host: Host, result: SshCommandResult) -> None: + now = _utcnow() + host.agent_update_last_at = now + if result.ok: + host.agent_update_state = "success" + host.agent_update_last_error = None + if result.agent_version: + host.product_version = result.agent_version + host.ssh_admin_ok = True + host.ssh_admin_checked_at = now + else: + host.agent_update_state = "failed" + host.agent_update_last_error = (result.message or "SSH update failed")[:2000] + + +def start_host_remote_action( + db: Session, + host: Host, + *, + title: str, + runner: ActionRunner, +) -> dict[str, Any]: + host_id = int(host.id) + with _lock: + if host_id in _running or host.agent_update_state == "running": + raise RemoteActionAlreadyRunningError( + f"Remote action already running for host {host.hostname}" + ) + _running.add(host_id) + + started_at = _mark_running(db, host, title=title) + db.commit() + + def _worker() -> None: + session = SessionLocal() + try: + row = session.get(Host, host_id) + if row is None: + return + result = runner(session, row) + started = (row.remote_action or {}).get("started_at") or started_at + row.remote_action = _result_payload(result, title=title, started_at=started) + if isinstance(result, SshCommandResult): + _apply_ssh_update_result(session, row, result) + session.commit() + except Exception: + logger.exception("Background remote action failed for host_id=%s", host_id) + session.rollback() + row = session.get(Host, host_id) + if row is not None: + started = (row.remote_action or {}).get("started_at") or started_at + row.agent_update_state = "failed" + row.agent_update_last_error = "Internal error during remote action" + row.remote_action = { + "title": title, + "status": "failed", + "message": "Внутренняя ошибка SAC при выполнении действия", + "stdout": "", + "stderr": "", + "output": "", + "ok": False, + "exit_code": None, + "product_version": None, + "target": row.hostname, + "started_at": started, + "finished_at": _utcnow().isoformat(), + } + session.commit() + finally: + session.close() + with _lock: + _running.discard(host_id) + + if _run_inline(): + _worker() + else: + threading.Thread(target=_worker, name=f"sac-remote-action-{host_id}", daemon=True).start() + return dict(host.remote_action or {}) + + +def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult: + cfg = get_effective_linux_admin_config(db) + if not cfg.configured: + return SshCommandResult( + ok=False, + message="Linux SSH admin is not configured", + target=host.hostname or "", + ) + agent_cfg = get_effective_agent_update_config(db) + repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None + branch = (agent_cfg.git_branch or "main").strip() or "main" + try: + targets = iter_ssh_targets(host) + except Exception as exc: + return SshCommandResult( + ok=False, + message=str(exc), + target=host.hostname or "", + ) + last: SshCommandResult | None = None + for target in targets: + last = run_ssh_monitor_update( + target=target, + user=cfg.user, + password=cfg.password, + repo_url=repo_url, + git_branch=branch, + ) + if last.ok: + return last + return last or SshCommandResult( + ok=False, + message="SSH update failed", + target=host.hostname or "", + ) + + +def run_agent_fallback_action(db: Session, host: Host) -> AgentUpdateFallbackResult: + return execute_agent_update_fallback(db, host) + + +def get_remote_action_status(host: Host) -> dict[str, Any]: + payload = dict(host.remote_action or {}) + if not payload: + return {"active": False, "host_id": host.id} + status = payload.get("status") or host.agent_update_state or "unknown" + active = status == "running" or host.agent_update_state == "running" + return { + "active": active, + "host_id": host.id, + "hostname": host.hostname, + "status": status, + "title": payload.get("title") or "", + "message": payload.get("message") or "", + "stdout": payload.get("stdout") or "", + "stderr": payload.get("stderr") or "", + "output": payload.get("output") or "", + "ok": payload.get("ok"), + "exit_code": payload.get("exit_code"), + "product_version": payload.get("product_version") or host.product_version, + "target": payload.get("target") or host.hostname, + "agent_update_state": host.agent_update_state, + "started_at": payload.get("started_at"), + "finished_at": payload.get("finished_at"), + } diff --git a/backend/app/version.py b/backend/app/version.py index 633e22d..68e882e 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.20.18" +APP_VERSION = "0.20.19" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b1b2054..d71005f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -104,16 +104,32 @@ def mock_agent_git_release(monkeypatch): ) +@pytest.fixture(autouse=True) +def reset_remote_action_state(): + from app.services import host_remote_actions + + with host_remote_actions._lock: + host_remote_actions._running.clear() + yield + with host_remote_actions._lock: + host_remote_actions._running.clear() + + @pytest.fixture -def client(db_session, monkeypatch): +def client(db_session, db_engine, monkeypatch): + monkeypatch.setenv("SAC_REMOTE_ACTION_INLINE", "1") monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None) monkeypatch.setattr("app.main.bootstrap_users", lambda: None) + test_session_local = sessionmaker(bind=db_engine, autocommit=False, autoflush=False) + monkeypatch.setattr("app.services.host_remote_actions.SessionLocal", test_session_local) + def override_get_db(): + session = test_session_local() try: - yield db_session + yield session finally: - pass + session.close() fastapi_app.dependency_overrides[get_db] = override_get_db with TestClient(fastapi_app) as c: diff --git a/backend/tests/test_agent_update.py b/backend/tests/test_agent_update.py index a5bd8a3..4858a76 100644 --- a/backend/tests/test_agent_update.py +++ b/backend/tests/test_agent_update.py @@ -1,5 +1,6 @@ """Tests for agent update settings, poll, request and ingest lifecycle.""" +import time import uuid from datetime import datetime, timedelta, timezone from unittest.mock import patch @@ -17,6 +18,21 @@ from app.services.ingest import ingest_event from tests.test_ingest import VALID_EVENT +def wait_remote_job(client, host_id, headers, timeout=5.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + response = client.get( + f"/api/v1/hosts/{host_id}/actions/remote-job", + headers=headers, + ) + assert response.status_code == 200 + body = response.json() + if not body.get("active") and body.get("status") != "running": + return body + time.sleep(0.05) + raise AssertionError("remote job did not finish in time") + + def test_agent_update_settings_sac_mode(db_session): cfg = upsert_agent_update_settings( db_session, @@ -203,6 +219,8 @@ def test_api_agent_update_fallback_ssh(jwt_headers, client, db_session, monkeypa headers=jwt_headers, ) - assert response.status_code == 200 - assert response.json()["ok"] is True - assert response.json()["product_version"] == "2.1.0-SAC" + assert response.status_code == 202 + assert response.json()["status"] == "running" + job = wait_remote_job(client, host.id, jwt_headers) + assert job["ok"] is True + assert job["product_version"] == "2.1.0-SAC" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 4e011b5..b798bdb 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.20.1" + assert APP_VERSION == "0.20.19" assert APP_NAME == "Security Alert Center" - assert APP_VERSION_LABEL == "Security Alert Center v.0.20.1" + assert APP_VERSION_LABEL == "Security Alert Center v.0.20.19" diff --git a/backend/tests/test_hosts.py b/backend/tests/test_hosts.py index d6f0ebf..499cbe4 100644 --- a/backend/tests/test_hosts.py +++ b/backend/tests/test_hosts.py @@ -4,6 +4,7 @@ import uuid from datetime import datetime, timedelta, timezone import pytest +from sqlalchemy import select from app.config import get_settings from app.models import Event, Host, Problem @@ -138,13 +139,14 @@ def test_delete_host_removes_events_and_problems(client, db_session, jwt_headers ) db_session.commit() - r = client.delete(f"/api/v1/hosts/{h.id}", headers=jwt_headers) + host_id = h.id + r = client.delete(f"/api/v1/hosts/{host_id}", headers=jwt_headers) assert r.status_code == 200 body = r.json() assert body["hostname"] == "test-host" assert body["deleted_events"] == 1 assert body["deleted_problems"] == 1 - assert db_session.get(Host, h.id) is None + assert db_session.scalar(select(Host.id).where(Host.id == host_id)) is None def test_delete_host_404(client, jwt_headers): diff --git a/backend/tests/test_linux_admin_settings.py b/backend/tests/test_linux_admin_settings.py index 0d17f80..3f7b8ec 100644 --- a/backend/tests/test_linux_admin_settings.py +++ b/backend/tests/test_linux_admin_settings.py @@ -1,8 +1,24 @@ """Additional Linux admin API tests.""" +import time from unittest.mock import patch +def wait_remote_job(client, host_id, headers, timeout=5.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + response = client.get( + f"/api/v1/hosts/{host_id}/actions/remote-job", + headers=headers, + ) + assert response.status_code == 200 + body = response.json() + if not body.get("active") and body.get("status") != "running": + return body + time.sleep(0.05) + raise AssertionError("remote job did not finish in time") + + def test_get_linux_admin_settings_env_default(jwt_headers, client, monkeypatch): monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "") monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "") @@ -137,7 +153,7 @@ def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch) db_session.commit() db_session.refresh(host) - with patch("app.api.v1.hosts.run_ssh_monitor_update") as mock_update: + with patch("app.services.host_remote_actions.run_ssh_monitor_update") as mock_update: mock_update.return_value = SshCommandResult( ok=True, message="SSH OK (ubabuba), exit 0\nSUMMARY updated", @@ -150,9 +166,11 @@ def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch) headers=jwt_headers, ) - assert response.status_code == 200 + assert response.status_code == 202 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) + assert body["status"] == "running" + job = wait_remote_job(client, host.id, jwt_headers) + assert job["ok"] is True + assert job["target"] == "ubabuba" + if "product_version" in job: + assert job["product_version"] is None or isinstance(job["product_version"], str) diff --git a/backend/tests/test_rdg_winrm_actions.py b/backend/tests/test_rdg_winrm_actions.py index a2aca3d..0cbb158 100644 --- a/backend/tests/test_rdg_winrm_actions.py +++ b/backend/tests/test_rdg_winrm_actions.py @@ -3,6 +3,8 @@ from datetime import datetime, timezone from unittest.mock import patch +from sqlalchemy import inspect as sa_inspect + from app.models import Event, Host from app.services.winrm_connect import WinRmCmdResult @@ -47,6 +49,7 @@ def test_qwinsta_via_winrm_on_client_host(jwt_headers, client, db_session, monke gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") db_session.add_all([ws, gw]) db_session.commit() + ws_id = ws.id event = _flap_event(db_session, gw=gw, ws=ws) qwinsta_out = " SESSIONNAME USERNAME ID STATE\r\n rdp-tcp#0 B26\\papatramp 2 Active\r\n" @@ -63,6 +66,10 @@ def test_qwinsta_via_winrm_on_client_host(jwt_headers, client, db_session, monke ["Andrisonova-PC=OK"], ) response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers) + mock_run.assert_called_once() + called_identity = sa_inspect(mock_run.call_args.args[0]).identity + assert called_identity is not None + assert called_identity[0] == ws_id assert response.status_code == 200 body = response.json() @@ -71,10 +78,6 @@ def test_qwinsta_via_winrm_on_client_host(jwt_headers, client, db_session, monke assert body["target"] == "Andrisonova-PC" assert "papatramp" in (body["result_stdout"] or "") - mock_run.assert_called_once() - called_host = mock_run.call_args.args[0] - assert called_host.hostname == "Andrisonova-PC" - def test_qwinsta_client_not_in_hosts(jwt_headers, client, db_session, monkeypatch): monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1b926c9..8d1f5ba 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,6 +4,16 @@ + @@ -12,6 +22,11 @@ import { computed, onMounted } from "vue"; import { useRoute } from "vue-router"; import { getToken } from "./api"; import AppSidebar from "./components/AppSidebar.vue"; +import HostActionLogModal from "./components/HostActionLogModal.vue"; +import { + closeHostRemoteActionLog, + hostRemoteActionLog, +} from "./composables/useHostRemoteAction"; import { refreshSessionRole } from "./router"; const route = useRoute(); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 76f860e..5978b67 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -517,8 +517,33 @@ export function testHostSsh(hostId: number): Promise { }); } -export function updateHostAgentViaSsh(hostId: number): Promise { - return apiFetch(`/api/v1/hosts/${hostId}/actions/agent-update`, { +export interface HostRemoteActionStartResult { + status: string; + host_id: number; + title: string; + message: string; +} + +export interface HostRemoteActionJobStatus { + active: boolean; + host_id: number; + hostname?: string | null; + status?: string | null; + title?: string; + message?: string; + stdout?: string | null; + stderr?: string | null; + output?: string | null; + ok?: boolean | null; + exit_code?: number | null; + product_version?: string | null; + agent_update_state?: string | null; + started_at?: string | null; + finished_at?: string | null; +} + +export function updateHostAgentViaSsh(hostId: number): Promise { + return apiFetch(`/api/v1/hosts/${hostId}/actions/agent-update`, { method: "POST", }); } @@ -537,10 +562,15 @@ export function requestHostAgentUpdate(hostId: number): Promise { - return apiFetch(`/api/v1/hosts/${hostId}/actions/agent-update-fallback`, { - method: "POST", - }); +export function runHostAgentUpdateFallback(hostId: number): Promise { + return apiFetch( + `/api/v1/hosts/${hostId}/actions/agent-update-fallback`, + { method: "POST" }, + ); +} + +export function fetchHostRemoteJob(hostId: number): Promise { + return apiFetch(`/api/v1/hosts/${hostId}/actions/remote-job`); } export interface HostAgentConfigResponse { diff --git a/frontend/src/composables/useHostRemoteAction.ts b/frontend/src/composables/useHostRemoteAction.ts new file mode 100644 index 0000000..e61d906 --- /dev/null +++ b/frontend/src/composables/useHostRemoteAction.ts @@ -0,0 +1,120 @@ +import { reactive } from "vue"; +import { + ApiError, + fetchHost, + fetchHostRemoteJob, + runHostAgentUpdateFallback, + updateHostAgentViaSsh, + type HostDetail, + type HostRemoteActionJobStatus, +} from "../api"; +import { emitHostPatch } from "../utils/hostPatchBus"; + +export type HostRemoteActionKind = "fallback" | "ssh-update"; + +export const hostRemoteActionLog = reactive({ + open: false, + title: "", + loading: false, + ok: null as boolean | null, + message: "", + output: "", + hostId: 0, +}); + +const POLL_MS = 1500; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function applyJobToModal(job: HostRemoteActionJobStatus) { + hostRemoteActionLog.title = job.title || hostRemoteActionLog.title; + hostRemoteActionLog.message = job.message || hostRemoteActionLog.message; + hostRemoteActionLog.output = job.output || job.stdout || ""; +} + +function finishModal(job: HostRemoteActionJobStatus) { + hostRemoteActionLog.loading = false; + hostRemoteActionLog.ok = job.ok ?? job.status === "success"; + applyJobToModal(job); +} + +async function pollRemoteJob(hostId: number): Promise { + for (;;) { + const job = await fetchHostRemoteJob(hostId); + applyJobToModal(job); + if (!job.active && job.status !== "running") { + return job; + } + await sleep(POLL_MS); + } +} + +async function patchHostFromJob(hostId: number, job: HostRemoteActionJobStatus) { + try { + const detail: HostDetail = await fetchHost(hostId); + if (job.product_version) { + detail.product_version = job.product_version; + } + if (job.agent_update_state) { + detail.agent_update_state = job.agent_update_state; + } + if (job.ok === false && job.message) { + detail.agent_update_last_error = job.message; + } + emitHostPatch(detail); + } catch { + /* list refresh on next open is enough */ + } +} + +async function startRemoteAction(hostId: number, kind: HostRemoteActionKind) { + if (kind === "fallback") { + await runHostAgentUpdateFallback(hostId); + return; + } + await updateHostAgentViaSsh(hostId); +} + +export async function runHostRemoteAction( + hostId: number, + title: string, + kind: HostRemoteActionKind, +): Promise { + hostRemoteActionLog.open = true; + hostRemoteActionLog.loading = true; + hostRemoteActionLog.ok = null; + hostRemoteActionLog.title = title; + hostRemoteActionLog.message = "Запуск на сервере SAC…"; + hostRemoteActionLog.output = ""; + hostRemoteActionLog.hostId = hostId; + + try { + try { + await startRemoteAction(hostId, kind); + } catch (e) { + if (e instanceof ApiError && e.status === 409) { + /* already running — poll existing job */ + } else { + throw e; + } + } + hostRemoteActionLog.message = + "Выполняется на удалённом хосте… Можно перейти в другой раздел SAC."; + const job = await pollRemoteJob(hostId); + finishModal(job); + await patchHostFromJob(hostId, job); + return job; + } catch (e) { + hostRemoteActionLog.loading = false; + hostRemoteActionLog.ok = false; + hostRemoteActionLog.message = e instanceof Error ? e.message : "Ошибка выполнения"; + return null; + } +} + +export function closeHostRemoteActionLog() { + if (hostRemoteActionLog.loading) return; + hostRemoteActionLog.open = false; +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 24989af..47f2697 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.20.18"; +export const APP_VERSION = "0.20.19"; 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 c538397..431d149 100644 --- a/frontend/src/views/HostDetailView.vue +++ b/frontend/src/views/HostDetailView.vue @@ -91,7 +91,7 @@ {{ runningAgentFallback ? "Обновление…" : "Обновить через WinRM" }} - {{ agentControlMessage }} + {{ agentControlMessage }} @@ -135,7 +135,7 @@ {{ runningAgentFallback ? "Fallback…" : "Fallback SSH" }} - {{ agentControlMessage }} + {{ agentControlMessage }} {{ sshTestMessage }} {{ sshTestOutput }} @@ -272,38 +272,28 @@ - -
{{ agentControlMessage }}
{{ sshTestMessage }}
{{ sshTestOutput }}