diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index bee0027..a809225 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -1,5 +1,7 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, status -from pydantic import BaseModel +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -176,6 +178,11 @@ def list_hosts( ) +class HostManualAddBody(BaseModel): + platform: Literal["windows", "linux"] + target: str = Field(..., min_length=1, max_length=253) + + def _host_detail_from_model( host: Host, *, @@ -298,6 +305,46 @@ class HostRemoteActionStartResponse(BaseModel): message: str +@router.post( + "/manual-add", + response_model=HostRemoteActionStartResponse, + status_code=status.HTTP_202_ACCEPTED, +) +def post_host_manual_add( + body: HostManualAddBody, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostRemoteActionStartResponse: + from app.services.host_manual_add import ManualHostAddError, prepare_manual_host_add + + try: + host = prepare_manual_host_add(db, platform=body.platform, target=body.target) + except ManualHostAddError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if body.platform == "windows": + title = f"Ручное добавление Windows: {host.hostname}" + else: + title = f"Ручное добавление Linux: {host.hostname}" + + 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", + ) + + class HostRemoteActionJobResponse(BaseModel): active: bool host_id: int diff --git a/backend/app/services/host_manual_add.py b/backend/app/services/host_manual_add.py new file mode 100644 index 0000000..bd00fb9 --- /dev/null +++ b/backend/app/services/host_manual_add.py @@ -0,0 +1,127 @@ +"""Probe remote host and register in SAC before agent deploy (WinRM / SSH).""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import Host +from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH +from app.services.linux_admin_settings import get_effective_linux_admin_config +from app.services.ssh_connect import _is_ipv4, _ssh_output_hostname, test_ssh_connection +from app.services.win_admin_settings import get_effective_win_admin_config +from app.services.winrm_connect import test_winrm_connection + +_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$") + + +class ManualHostAddError(Exception): + pass + + +def validate_windows_target(target: str) -> str: + text = (target or "").strip() + if not text: + raise ManualHostAddError("Укажите имя Windows-ПК") + if _IPV4_RE.match(text): + raise ManualHostAddError( + "Для Windows укажите имя ПК (NetBIOS/DNS), не IP — WinRM с Kerberos по IP ненадёжен" + ) + short = text.split(".")[0].split("\\")[-1].strip() + if not short or " " in short: + raise ManualHostAddError("Некорректное имя хоста") + return short + + +def validate_linux_target(target: str) -> str: + text = (target or "").strip() + if not text: + raise ManualHostAddError("Укажите IP или hostname Linux-хоста") + return text + + +def probe_windows_host(db: Session, hostname: str) -> str: + cfg = get_effective_win_admin_config(db) + if not cfg.configured: + raise ManualHostAddError("Windows domain admin не настроен в SAC") + probe = test_winrm_connection( + target=hostname, + user=cfg.user, + password=cfg.password, + ) + if not probe.ok: + raise ManualHostAddError(probe.message) + return (probe.hostname or hostname).strip() + + +def probe_linux_host(db: Session, target: str) -> tuple[str, str | None]: + cfg = get_effective_linux_admin_config(db) + if not cfg.configured: + raise ManualHostAddError("Linux SSH admin не настроен в SAC") + probe = test_ssh_connection( + target=target, + user=cfg.user, + password=cfg.password, + ) + if not probe.ok: + raise ManualHostAddError(probe.message) + hostname = _ssh_output_hostname(probe.stdout) or target + ipv4 = target if _is_ipv4(target) else None + return hostname, ipv4 + + +def get_or_create_manual_host( + db: Session, + *, + hostname: str, + product: str, + os_family: str, + ipv4: str | None = None, +) -> Host: + host = db.scalar( + select(Host).where(Host.hostname == hostname, Host.product == product).limit(1) + ) + now = datetime.now(timezone.utc) + if host is None: + host = Host( + hostname=hostname, + os_family=os_family, + product=product, + ipv4=ipv4, + last_seen_at=now, + ) + db.add(host) + else: + host.os_family = os_family + if ipv4: + host.ipv4 = ipv4 + host.last_seen_at = now + db.flush() + return host + + +def prepare_manual_host_add(db: Session, *, platform: str, target: str) -> Host: + platform_norm = (platform or "").strip().lower() + if platform_norm == "windows": + win_target = validate_windows_target(target) + hostname = probe_windows_host(db, win_target) + return get_or_create_manual_host( + db, + hostname=hostname, + product=PRODUCT_RDP, + os_family="windows", + ) + if platform_norm == "linux": + linux_target = validate_linux_target(target) + hostname, ipv4 = probe_linux_host(db, linux_target) + return get_or_create_manual_host( + db, + hostname=hostname, + product=PRODUCT_SSH, + os_family="linux", + ipv4=ipv4, + ) + raise ManualHostAddError("platform должен быть windows или linux") diff --git a/backend/app/version.py b/backend/app/version.py index aa2ac9c..520c99c 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.4.4" +APP_VERSION = "0.4.5" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_host_manual_add.py b/backend/tests/test_host_manual_add.py new file mode 100644 index 0000000..0147c4e --- /dev/null +++ b/backend/tests/test_host_manual_add.py @@ -0,0 +1,141 @@ +"""Manual host add: validation, probe, deploy kick-off.""" + +from unittest.mock import patch + +import pytest + +from app.models import Host +from app.services.host_manual_add import ( + ManualHostAddError, + prepare_manual_host_add, + validate_linux_target, + validate_windows_target, +) +from app.services.winrm_connect import WinRmTestResult +from tests.test_agent_update import wait_remote_job + + +def test_validate_windows_rejects_ip(): + with pytest.raises(ManualHostAddError, match="не IP"): + validate_windows_target("10.10.36.9") + + +def test_validate_windows_accepts_hostname(): + assert validate_windows_target("WORKSTATION-01") == "WORKSTATION-01" + assert validate_windows_target(r"B26\WORKSTATION-01") == "WORKSTATION-01" + + +def test_validate_linux_accepts_ip(): + assert validate_linux_target("10.10.36.9") == "10.10.36.9" + + +def test_prepare_manual_host_windows(db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + with patch("app.services.host_manual_add.test_winrm_connection") as mock_win: + mock_win.return_value = WinRmTestResult( + ok=True, + message="WinRM OK, hostname=WORKSTATION-01", + target="WORKSTATION-01", + hostname="WORKSTATION-01", + ) + host = prepare_manual_host_add( + db_session, + platform="windows", + target="WORKSTATION-01", + ) + assert host.hostname == "WORKSTATION-01" + assert host.product == "rdp-login-monitor" + assert host.os_family == "windows" + + +def test_api_manual_add_windows(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + with patch("app.services.host_manual_add.test_winrm_connection") as mock_win: + mock_win.return_value = WinRmTestResult( + ok=True, + message="WinRM OK, hostname=NEW-PC", + target="NEW-PC", + hostname="NEW-PC", + ) + with patch("app.services.agent_update.run_winrm_rdp_monitor_update") as mock_deploy: + from app.services.winrm_connect import WinRmCmdResult + + mock_deploy.return_value = WinRmCmdResult( + ok=True, + message="deploy ok", + target="NEW-PC", + stdout="2.1.8-SAC", + ) + response = client.post( + "/api/v1/hosts/manual-add", + json={"platform": "windows", "target": "NEW-PC"}, + headers=jwt_headers, + ) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "running" + host_id = body["host_id"] + host = db_session.get(Host, host_id) + assert host is not None + assert host.hostname == "NEW-PC" + job = wait_remote_job(client, host_id, jwt_headers) + assert job["ok"] is True + + +def test_api_manual_add_rejects_windows_ip(jwt_headers, client): + response = client.post( + "/api/v1/hosts/manual-add", + json={"platform": "windows", "target": "192.168.1.10"}, + headers=jwt_headers, + ) + assert response.status_code == 400 + assert "не IP" in response.json()["detail"] + + +def test_api_manual_add_linux(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root") + monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + with patch("app.services.host_manual_add.test_ssh_connection") as mock_ssh: + from app.services.ssh_connect import SshCommandResult + + mock_ssh.return_value = SshCommandResult( + ok=True, + message="SSH OK, hostname=linux-srv", + target="10.10.36.9", + stdout="linux-srv\n", + ) + with patch("app.services.agent_update.run_ssh_monitor_update") as mock_update: + mock_update.return_value = SshCommandResult( + ok=True, + message="updated", + target="10.10.36.9", + agent_version="2.1.5-SAC", + ) + response = client.post( + "/api/v1/hosts/manual-add", + json={"platform": "linux", "target": "10.10.36.9"}, + headers=jwt_headers, + ) + + assert response.status_code == 202 + host_id = response.json()["host_id"] + host = db_session.get(Host, host_id) + assert host.hostname == "linux-srv" + assert host.ipv4 == "10.10.36.9" + job = wait_remote_job(client, host_id, jwt_headers, timeout=8.0) + assert job["ok"] is True diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d79195c..a9b068f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -575,6 +575,18 @@ export function runHostAgentUpdateFallback(hostId: number): Promise { + return apiFetch("/api/v1/hosts/manual-add", { + method: "POST", + body: JSON.stringify(body), + }); +} + export function fetchHostRemoteJob(hostId: number): Promise { return apiFetch(`/api/v1/hosts/${hostId}/actions/remote-job`); } diff --git a/frontend/src/components/HostManualAddModal.vue b/frontend/src/components/HostManualAddModal.vue new file mode 100644 index 0000000..aeff581 --- /dev/null +++ b/frontend/src/components/HostManualAddModal.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/frontend/src/composables/useHostRemoteAction.ts b/frontend/src/composables/useHostRemoteAction.ts index 1605fd5..18cea02 100644 --- a/frontend/src/composables/useHostRemoteAction.ts +++ b/frontend/src/composables/useHostRemoteAction.ts @@ -163,6 +163,54 @@ async function executeRemoteAction( } } +export async function pollHostRemoteAction( + hostId: number, + title: string, +): Promise { + const existingPromise = hostRunningPromises.get(hostId); + if (existingPromise) { + const existing = findLoadingLogForHost(hostId); + if (existing) { + existing.open = true; + } + return existingPromise; + } + + const entry: HostRemoteActionLogEntry = { + id: newLogId(), + hostId, + open: true, + loading: true, + ok: null, + title, + message: "Выполняется на удалённом хосте…", + output: "", + }; + hostRemoteActionLogs.push(entry); + + const promise = (async () => { + try { + const job = await pollRemoteJob(hostId, entry); + finishEntry(entry, job); + await patchHostFromJob(hostId, job); + return job; + } catch (e) { + entry.loading = false; + entry.ok = false; + entry.message = e instanceof Error ? e.message : "Ошибка выполнения"; + entry.open = true; + return null; + } + })(); + + hostRunningPromises.set(hostId, promise); + try { + return await promise; + } finally { + hostRunningPromises.delete(hostId); + } +} + export async function runHostRemoteAction( hostId: number, title: string, diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 9adabf0..cb20833 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.4.4"; +export const APP_VERSION = "0.4.5"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/HostsView.vue b/frontend/src/views/HostsView.vue index 31104ca..8d28197 100644 --- a/frontend/src/views/HostsView.vue +++ b/frontend/src/views/HostsView.vue @@ -15,6 +15,7 @@ +

{{ error }}

Загрузка…

@@ -108,6 +109,11 @@ +