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:
@@ -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
|
||||
|
||||
@@ -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,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}"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user