feat: manual host add from Hosts list via WinRM/SSH (0.4.5)

Add host button probes Windows by hostname or Linux by IP, registers host in SAC and deploys agent in background.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-25 15:34:12 +10:00
parent 5a7909bb55
commit fd1be5e7e4
9 changed files with 550 additions and 4 deletions
+49 -2
View File
@@ -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
+127
View File
@@ -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")
+1 -1
View File
@@ -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}"
+141
View File
@@ -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