feat: background remote agent updates survive SAC navigation (0.20.19)

This commit is contained in:
PTah
2026-06-21 11:01:26 +10:00
parent d7bbcc5337
commit 5635be1322
16 changed files with 642 additions and 161 deletions
@@ -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")
+99 -35
View File
@@ -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,
+1
View File
@@ -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())
+245
View File
@@ -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"),
}
+1 -1
View File
@@ -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}"
+19 -3
View File
@@ -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:
+21 -3
View File
@@ -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"
+2 -2
View File
@@ -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"
+4 -2
View File
@@ -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):
+24 -6
View File
@@ -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)
+7 -4
View File
@@ -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")
+15
View File
@@ -4,6 +4,16 @@
<main :class="showShell ? 'app-main' : ''">
<RouterView />
</main>
<HostActionLogModal
v-if="showShell"
:open="hostRemoteActionLog.open"
:title="hostRemoteActionLog.title"
:loading="hostRemoteActionLog.loading"
:ok="hostRemoteActionLog.ok"
:message="hostRemoteActionLog.message"
:output="hostRemoteActionLog.output"
@close="closeHostRemoteActionLog"
/>
</div>
</template>
@@ -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();
+36 -6
View File
@@ -517,8 +517,33 @@ export function testHostSsh(hostId: number): Promise<HostSshActionResult> {
});
}
export function updateHostAgentViaSsh(hostId: number): Promise<HostSshActionResult> {
return apiFetch<HostSshActionResult>(`/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<HostRemoteActionStartResult> {
return apiFetch<HostRemoteActionStartResult>(`/api/v1/hosts/${hostId}/actions/agent-update`, {
method: "POST",
});
}
@@ -537,10 +562,15 @@ export function requestHostAgentUpdate(hostId: number): Promise<HostAgentUpdateR
);
}
export function runHostAgentUpdateFallback(hostId: number): Promise<HostSshActionResult> {
return apiFetch<HostSshActionResult>(`/api/v1/hosts/${hostId}/actions/agent-update-fallback`, {
method: "POST",
});
export function runHostAgentUpdateFallback(hostId: number): Promise<HostRemoteActionStartResult> {
return apiFetch<HostRemoteActionStartResult>(
`/api/v1/hosts/${hostId}/actions/agent-update-fallback`,
{ method: "POST" },
);
}
export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`);
}
export interface HostAgentConfigResponse {
@@ -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<void> {
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<HostRemoteActionJobStatus> {
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<HostRemoteActionJobStatus | null> {
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;
}
+1 -1
View File
@@ -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}`;
+20 -98
View File
@@ -91,7 +91,7 @@
{{ runningAgentFallback ? "Обновление…" : "Обновить через WinRM" }}
</button>
</div>
<p v-if="agentControlMessage && !actionLog.open" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
<p v-if="agentControlMessage && !hostRemoteActionLog.open" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
</div>
<div v-if="isLinuxHost" class="host-actions">
<div class="host-actions-row">
@@ -135,7 +135,7 @@
{{ runningAgentFallback ? "Fallback…" : "Fallback SSH" }}
</button>
</div>
<p v-if="agentControlMessage && !actionLog.open" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
<p v-if="agentControlMessage && !hostRemoteActionLog.open" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
<div v-if="showSshTestFeedback && (sshTestMessage || sshTestOutput)" class="host-action-feedback">
<p :class="sshTestOk ? 'success' : 'error'">{{ sshTestMessage }}</p>
<pre v-if="sshTestOutput" class="host-ssh-output">{{ sshTestOutput }}</pre>
@@ -272,38 +272,28 @@
</div>
</template>
</template>
<HostActionLogModal
:open="actionLog.open"
:title="actionLog.title"
:loading="actionLog.loading"
:ok="actionLog.ok"
:message="actionLog.message"
:output="actionLog.output"
@close="closeActionLog"
/>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
import {
hostRemoteActionLog,
runHostRemoteAction,
} from "../composables/useHostRemoteAction";
import {
apiFetch,
fetchHost,
fetchRecentEvents,
testHostWinRm,
testHostSsh,
updateHostAgentViaSsh,
requestHostAgentUpdate,
runHostAgentUpdateFallback,
patchHostAgentConfig,
type EventListResponse,
type HostDetail,
type HostSshActionResult,
} from "../api";
import { emitHostPatch } from "../utils/hostPatchBus";
import HostActionLogModal from "../components/HostActionLogModal.vue";
const HOST_ACTION_FEEDBACK_MS = 30_000;
@@ -328,14 +318,6 @@ const showSshTestFeedback = ref(false);
const sshTestMessage = ref("");
const sshTestOk = ref(false);
const sshTestOutput = ref("");
const actionLog = reactive({
open: false,
title: "",
loading: false,
ok: null as boolean | null,
message: "",
output: "",
});
const requestingAgentUpdate = ref(false);
const runningAgentFallback = ref(false);
const agentControlMessage = ref("");
@@ -609,59 +591,6 @@ function formatSshOutput(result: { stdout: string | null; stderr: string | null;
return parts.join("\n\n");
}
function openActionLog(title: string) {
actionLog.open = true;
actionLog.title = title;
actionLog.loading = true;
actionLog.ok = null;
actionLog.message = "Подключение к хосту и выполнение команды…";
actionLog.output = "";
}
function finishActionLog(result: { ok: boolean; message: string; output?: string }) {
actionLog.loading = false;
actionLog.ok = result.ok;
actionLog.message = result.message;
actionLog.output = result.output ?? "";
}
function closeActionLog() {
if (actionLog.loading) return;
actionLog.open = false;
}
async function runRemoteHostAction(
title: string,
request: () => Promise<HostSshActionResult>,
options: { refreshHost?: boolean; applyProductVersion?: boolean } = {},
) {
openActionLog(title);
try {
const result = await request();
finishActionLog({
ok: result.ok,
message: result.message,
output: formatSshOutput(result),
});
if (result.ok && result.product_version && host.value && options.applyProductVersion !== false) {
host.value = { ...host.value, product_version: result.product_version };
emitHostPatch(host.value);
} else if (host.value && result.ssh_admin_ok != null) {
host.value = { ...host.value, ssh_admin_ok: result.ssh_admin_ok };
}
if (options.refreshHost) {
await loadHost({ preserveActionLog: true });
}
return result;
} catch (e) {
finishActionLog({
ok: false,
message: e instanceof Error ? e.message : "Ошибка выполнения",
});
return null;
}
}
function applyHostDetail(detail: HostDetail) {
host.value = detail;
syncConfigFormFromHost(detail);
@@ -691,14 +620,13 @@ async function runAgentFallback() {
agentControlMessage.value = "";
const title = isWindowsHost.value ? "Обновление через WinRM" : "Fallback SSH (ssh-monitor)";
try {
const result = await runRemoteHostAction(
title,
() => runHostAgentUpdateFallback(hostId.value),
{ refreshHost: true },
);
if (result) {
agentControlOk.value = result.ok;
agentControlMessage.value = result.message;
const job = await runHostRemoteAction(hostId.value, title, "fallback");
if (job) {
agentControlOk.value = job.ok ?? false;
agentControlMessage.value = job.message || "";
if (job.ok) {
await loadHost();
}
}
} finally {
runningAgentFallback.value = false;
@@ -732,7 +660,7 @@ async function saveAgentConfig() {
}
}
async function loadHost(options: { preserveActionLog?: boolean } = {}) {
async function loadHost() {
loading.value = true;
error.value = "";
winRmTestMessage.value = "";
@@ -743,9 +671,6 @@ async function loadHost(options: { preserveActionLog?: boolean } = {}) {
showSshTestFeedback.value = false;
sshTestMessage.value = "";
sshTestOutput.value = "";
if (!options.preserveActionLog) {
closeActionLog();
}
try {
const detail = await fetchHost(hostId.value);
applyHostDetail(detail);
@@ -762,18 +687,15 @@ async function loadHost(options: { preserveActionLog?: boolean } = {}) {
async function runAgentUpdate() {
updatingAgent.value = true;
try {
const result = await runRemoteHostAction(
const job = await runHostRemoteAction(
hostId.value,
"Обновление ssh-monitor (SSH)",
() => updateHostAgentViaSsh(hostId.value),
{ refreshHost: false },
"ssh-update",
);
if (result?.ok) {
if (job?.ok) {
const detail = await fetchHost(hostId.value);
if (result.product_version) {
detail.product_version = result.product_version;
}
if (result.ssh_admin_ok != null) {
detail.ssh_admin_ok = result.ssh_admin_ok;
if (job.product_version) {
detail.product_version = job.product_version;
}
applyHostDetail(detail);
}